Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4594bd2307 | |||
| d8c0052b54 | |||
| 4d1d94296f | |||
| c6800a6144 | |||
| 000e90a617 | |||
| 70e9465b68 | |||
| 8c3e0f9554 |
@@ -14,7 +14,29 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
packages: read
|
||||||
steps:
|
steps:
|
||||||
|
- name: Validate tag format
|
||||||
|
run: |
|
||||||
|
TAG="${{ inputs.tag }}"
|
||||||
|
if ! echo "$TAG" | grep -qE '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[a-f0-9]{7}$'; then
|
||||||
|
echo "::error::Invalid tag format: '$TAG'. Expected format: YYYY.MM.DD-sha7 (e.g. 2026.03.28-f1b85bf)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Tag format valid: $TAG"
|
||||||
|
|
||||||
|
- name: Verify image exists in GHCR
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
TAG="${{ inputs.tag }}"
|
||||||
|
# Check that the API image exists — if API was pushed, web/migrate were too
|
||||||
|
if ! gh api "/orgs/groombook/packages/container/api/versions" --jq ".[].metadata.container.tags[]" 2>/dev/null | grep -qF "$TAG"; then
|
||||||
|
echo "::error::Image ghcr.io/groombook/api:$TAG not found in GHCR. Verify the tag was built and pushed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Image verified: ghcr.io/groombook/api:$TAG exists"
|
||||||
|
|
||||||
- name: Generate infra repo token
|
- name: Generate infra repo token
|
||||||
id: infra-token
|
id: infra-token
|
||||||
uses: tibdex/github-app-token@v2
|
uses: tibdex/github-app-token@v2
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ RUN pnpm install --frozen-lockfile
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
FROM deps AS builder
|
FROM deps AS builder
|
||||||
|
RUN mkdir -p /home/node/.cache/node/corepack
|
||||||
COPY packages/ packages/
|
COPY packages/ packages/
|
||||||
COPY apps/api/ apps/api/
|
COPY apps/api/ apps/api/
|
||||||
RUN pnpm --filter @groombook/types build && \
|
RUN pnpm --filter @groombook/types build && \
|
||||||
|
|||||||
@@ -142,8 +142,8 @@ describe("auth init", () => {
|
|||||||
...originalEnv,
|
...originalEnv,
|
||||||
AUTH_DISABLED: "true",
|
AUTH_DISABLED: "true",
|
||||||
NODE_ENV: "test",
|
NODE_ENV: "test",
|
||||||
|
BETTER_AUTH_SECRET: "placeholder-for-test-only",
|
||||||
};
|
};
|
||||||
delete process.env.BETTER_AUTH_SECRET;
|
|
||||||
|
|
||||||
const { initAuth, getAuth } = await reimportAuth();
|
const { initAuth, getAuth } = await reimportAuth();
|
||||||
await expect(initAuth()).resolves.toBeUndefined();
|
await expect(initAuth()).resolves.toBeUndefined();
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ const BASE_APPT = {
|
|||||||
|
|
||||||
// ─── Shared mock DB state ─────────────────────────────────────────────────────
|
// ─── Shared mock DB state ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
let mockAppt: typeof BASE_APPT | null = BASE_APPT;
|
let mockAppt: (typeof BASE_APPT & { confirmationToken: string }) | null = BASE_APPT as typeof BASE_APPT & { confirmationToken: string };
|
||||||
let lastUpdate: Record<string, unknown> = {};
|
let lastUpdate: Record<string, unknown> = {};
|
||||||
|
|
||||||
function resetMock() {
|
function resetMock() {
|
||||||
mockAppt = { ...BASE_APPT };
|
mockAppt = { ...BASE_APPT, confirmationToken: "valid-token-abc123" } as typeof BASE_APPT & { confirmationToken: string };
|
||||||
lastUpdate = {};
|
lastUpdate = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,19 +55,39 @@ vi.mock("@groombook/db", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
update: () => ({
|
update: () => ({
|
||||||
set: (vals: Record<string, unknown>) => ({
|
set: (vals: Record<string, unknown>) => {
|
||||||
where: () => {
|
const setVals = vals;
|
||||||
lastUpdate = { ...vals };
|
return {
|
||||||
if (mockAppt) {
|
where: () => {
|
||||||
mockAppt = { ...mockAppt, ...vals } as typeof BASE_APPT;
|
const preUpdate = mockAppt ? { ...mockAppt } : null;
|
||||||
}
|
const preStatus = preUpdate?.confirmationStatus;
|
||||||
return { returning: () => (mockAppt ? [mockAppt] : []) };
|
const preStart = preUpdate?.startTime;
|
||||||
},
|
lastUpdate = { ...setVals };
|
||||||
}),
|
const whereMatched =
|
||||||
|
preUpdate != null &&
|
||||||
|
preStatus === "pending" &&
|
||||||
|
preStart != null &&
|
||||||
|
preStart > new Date();
|
||||||
|
if (whereMatched && mockAppt) {
|
||||||
|
mockAppt = { ...mockAppt, ...setVals } as typeof BASE_APPT & { confirmationToken: string };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
returning: () => {
|
||||||
|
if (!preUpdate) return [];
|
||||||
|
if (preStatus !== "pending") return [];
|
||||||
|
if (preStart && preStart <= new Date()) return [];
|
||||||
|
return whereMatched && mockAppt ? [mockAppt] : [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
appointments,
|
appointments,
|
||||||
eq: () => ({}),
|
eq: () => ({}),
|
||||||
|
and: (a: unknown, b: unknown, c?: unknown) => (c ? [a, b, c] : [a, b]),
|
||||||
|
gt: () => ({}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ describe("requireRoleOrSuperUser", () => {
|
|||||||
const res = await app.request("/test");
|
const res = await app.request("/test");
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toMatch(/super user privileges required/i);
|
expect(body.error).toMatch(/role 'receptionist' is not permitted/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("blocks a non-super-user groomer from manager-only routes", async () => {
|
it("blocks a non-super-user groomer from manager-only routes", async () => {
|
||||||
@@ -370,7 +370,7 @@ describe("requireRoleOrSuperUser", () => {
|
|||||||
const res = await app.request("/test");
|
const res = await app.request("/test");
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toMatch(/super user privileges required/i);
|
expect(body.error).toMatch(/role 'groomer' is not permitted/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows a manager with multiple allowed roles", async () => {
|
it("allows a manager with multiple allowed roles", async () => {
|
||||||
|
|||||||
@@ -42,6 +42,23 @@ app.use(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// CSRF protection for state-changing requests
|
||||||
|
app.use("/api/*", async (c, next) => {
|
||||||
|
const method = c.req.method;
|
||||||
|
if (["GET", "HEAD", "OPTIONS"].includes(method)) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const origin = c.req.header("origin");
|
||||||
|
const trustedOrigin = process.env.CORS_ORIGIN ?? "http://localhost:5173";
|
||||||
|
if (origin && origin !== trustedOrigin) {
|
||||||
|
c.status(403);
|
||||||
|
c.json({ error: "CSRF validation failed: origin mismatch" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
// Health check (no auth required)
|
// Health check (no auth required)
|
||||||
app.get("/health", (c) => c.json({ status: "ok" }));
|
app.get("/health", (c) => c.json({ status: "ok" }));
|
||||||
|
|
||||||
|
|||||||
+35
-11
@@ -86,10 +86,15 @@ export async function initAuth(): Promise<void> {
|
|||||||
// AUTH_DISABLED=true means dev/demo mode — still build Better-Auth with placeholder
|
// AUTH_DISABLED=true means dev/demo mode — still build Better-Auth with placeholder
|
||||||
// config so auth.handler exists (middleware bypasses it anyway)
|
// config so auth.handler exists (middleware bypasses it anyway)
|
||||||
if (process.env.AUTH_DISABLED === "true") {
|
if (process.env.AUTH_DISABLED === "true") {
|
||||||
|
if (!BETTER_AUTH_SECRET) {
|
||||||
|
throw new Error(
|
||||||
|
"[FATAL] BETTER_AUTH_SECRET must be set when AUTH_DISABLED=true"
|
||||||
|
);
|
||||||
|
}
|
||||||
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
|
console.warn("[auth] AUTH_DISABLED=true — building placeholder auth instance");
|
||||||
authInstance = betterAuth({
|
authInstance = betterAuth({
|
||||||
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
database: drizzleAdapter(getDb(), { provider: "pg" }),
|
||||||
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
|
secret: BETTER_AUTH_SECRET,
|
||||||
baseURL: BETTER_AUTH_URL,
|
baseURL: BETTER_AUTH_URL,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -199,20 +204,36 @@ export async function initAuth(): Promise<void> {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const validateIssuerHost = (url: string, issuerUrl: string): boolean => {
|
||||||
|
try {
|
||||||
|
const discovered = new URL(url);
|
||||||
|
const expected = new URL(issuerUrl);
|
||||||
|
return discovered.hostname === expected.hostname;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
const authzUrl = discovery.authorization_endpoint;
|
const authzUrl = discovery.authorization_endpoint;
|
||||||
const tokenUrl = discovery.token_endpoint;
|
const tokenUrl = discovery.token_endpoint;
|
||||||
const userInfoUrl = discovery.userinfo_endpoint;
|
const userInfoUrl = discovery.userinfo_endpoint;
|
||||||
if (authzUrl && tokenUrl && userInfoUrl) {
|
if (authzUrl && tokenUrl && userInfoUrl) {
|
||||||
oidcConfig = {
|
const validAuthz = validateIssuerHost(authzUrl, providerConfig.issuerUrl);
|
||||||
authorizationUrl: authzUrl,
|
const validToken = validateIssuerHost(tokenUrl, providerConfig.issuerUrl);
|
||||||
tokenUrl: providerConfig.internalBaseUrl
|
const validUserInfo = validateIssuerHost(userInfoUrl, providerConfig.issuerUrl);
|
||||||
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
|
if (!validAuthz || !validToken || !validUserInfo) {
|
||||||
: tokenUrl,
|
console.warn("[auth] OIDC discovery URL host mismatch — possible redirection attack, rejecting");
|
||||||
userInfoUrl: providerConfig.internalBaseUrl
|
} else {
|
||||||
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
|
oidcConfig = {
|
||||||
: userInfoUrl,
|
authorizationUrl: authzUrl,
|
||||||
};
|
tokenUrl: providerConfig.internalBaseUrl
|
||||||
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
|
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
|
||||||
|
: tokenUrl,
|
||||||
|
userInfoUrl: providerConfig.internalBaseUrl
|
||||||
|
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
|
||||||
|
: userInfoUrl,
|
||||||
|
};
|
||||||
|
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
|
console.warn("[auth] OIDC discovery missing required endpoints, using discoveryUrl only");
|
||||||
}
|
}
|
||||||
@@ -287,6 +308,9 @@ export async function initAuth(): Promise<void> {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
maxAge: 5 * 60, // 5 minutes
|
maxAge: 5 * 60, // 5 minutes
|
||||||
},
|
},
|
||||||
|
cookieAttributes: {
|
||||||
|
sameSite: "strict",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
trustedOrigins: [process.env.CORS_ORIGIN ?? "http://localhost:5173"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -149,9 +149,9 @@ export function requireRoleOrSuperUser(
|
|||||||
}
|
}
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
error: staffRow.isSuperUser
|
error: hasAllowedRole
|
||||||
? `Forbidden: role '${staffRow.role}' is not permitted`
|
? "Forbidden: super user privileges required"
|
||||||
: "Forbidden: super user privileges required",
|
: `Forbidden: role '${staffRow.role}' is not permitted`,
|
||||||
},
|
},
|
||||||
403
|
403
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -16,9 +16,8 @@ import {
|
|||||||
services,
|
services,
|
||||||
staff,
|
staff,
|
||||||
} from "@groombook/db";
|
} from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
|
||||||
|
|
||||||
export const appointmentGroupsRouter = new Hono<AppEnv>();
|
export const appointmentGroupsRouter = new Hono();
|
||||||
|
|
||||||
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
// ─── Schemas ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -50,8 +49,6 @@ appointmentGroupsRouter.get("/", async (c) => {
|
|||||||
const clientId = c.req.query("clientId");
|
const clientId = c.req.query("clientId");
|
||||||
const from = c.req.query("from");
|
const from = c.req.query("from");
|
||||||
const to = c.req.query("to");
|
const to = c.req.query("to");
|
||||||
const staffRow = c.get("staff");
|
|
||||||
const isGroomer = staffRow?.role === "groomer";
|
|
||||||
|
|
||||||
const groupConditions = clientId
|
const groupConditions = clientId
|
||||||
? [eq(appointmentGroups.clientId, clientId)]
|
? [eq(appointmentGroups.clientId, clientId)]
|
||||||
@@ -91,16 +88,6 @@ appointmentGroupsRouter.get("/", async (c) => {
|
|||||||
}))
|
}))
|
||||||
.filter((g) => !from || g.appointments.length > 0);
|
.filter((g) => !from || g.appointments.length > 0);
|
||||||
|
|
||||||
if (isGroomer) {
|
|
||||||
return c.json(
|
|
||||||
result.filter((g) =>
|
|
||||||
g.appointments.some(
|
|
||||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(result);
|
return c.json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,8 +96,6 @@ appointmentGroupsRouter.get("/", async (c) => {
|
|||||||
appointmentGroupsRouter.get("/:id", async (c) => {
|
appointmentGroupsRouter.get("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const staffRow = c.get("staff");
|
|
||||||
const isGroomer = staffRow?.role === "groomer";
|
|
||||||
|
|
||||||
const [group] = await db
|
const [group] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -126,7 +111,6 @@ appointmentGroupsRouter.get("/:id", async (c) => {
|
|||||||
serviceId: appointments.serviceId,
|
serviceId: appointments.serviceId,
|
||||||
serviceName: services.name,
|
serviceName: services.name,
|
||||||
staffId: appointments.staffId,
|
staffId: appointments.staffId,
|
||||||
batherStaffId: appointments.batherStaffId,
|
|
||||||
staffName: staff.name,
|
staffName: staff.name,
|
||||||
status: appointments.status,
|
status: appointments.status,
|
||||||
startTime: appointments.startTime,
|
startTime: appointments.startTime,
|
||||||
@@ -141,15 +125,6 @@ appointmentGroupsRouter.get("/:id", async (c) => {
|
|||||||
.where(eq(appointments.groupId, id))
|
.where(eq(appointments.groupId, id))
|
||||||
.orderBy(appointments.startTime);
|
.orderBy(appointments.startTime);
|
||||||
|
|
||||||
if (
|
|
||||||
isGroomer &&
|
|
||||||
!groupAppts.some(
|
|
||||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [client] = await db
|
const [client] = await db
|
||||||
.select({ name: clients.name, email: clients.email })
|
.select({ name: clients.name, email: clients.email })
|
||||||
.from(clients)
|
.from(clients)
|
||||||
@@ -165,13 +140,6 @@ appointmentGroupsRouter.post(
|
|||||||
zValidator("json", createGroupSchema),
|
zValidator("json", createGroupSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const staffRow = c.get("staff");
|
|
||||||
if (staffRow?.role === "groomer") {
|
|
||||||
return c.json(
|
|
||||||
{ error: "Forbidden: groomers cannot create group bookings" },
|
|
||||||
403
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const startTime = new Date(body.startTime);
|
const startTime = new Date(body.startTime);
|
||||||
|
|
||||||
@@ -276,28 +244,6 @@ appointmentGroupsRouter.patch(
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const staffRow = c.get("staff");
|
|
||||||
const isGroomer = staffRow?.role === "groomer";
|
|
||||||
|
|
||||||
const [group] = await db
|
|
||||||
.select({ id: appointmentGroups.id })
|
|
||||||
.from(appointmentGroups)
|
|
||||||
.where(eq(appointmentGroups.id, id));
|
|
||||||
if (!group) return c.json({ error: "Not found" }, 404);
|
|
||||||
|
|
||||||
if (isGroomer) {
|
|
||||||
const groupAppts = await db
|
|
||||||
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
|
|
||||||
.from(appointments)
|
|
||||||
.where(eq(appointments.groupId, id));
|
|
||||||
if (
|
|
||||||
!groupAppts.some(
|
|
||||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(appointmentGroups)
|
.update(appointmentGroups)
|
||||||
@@ -315,8 +261,6 @@ appointmentGroupsRouter.patch(
|
|||||||
appointmentGroupsRouter.delete("/:id", async (c) => {
|
appointmentGroupsRouter.delete("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const staffRow = c.get("staff");
|
|
||||||
const isGroomer = staffRow?.role === "groomer";
|
|
||||||
|
|
||||||
const [group] = await db
|
const [group] = await db
|
||||||
.select({ id: appointmentGroups.id })
|
.select({ id: appointmentGroups.id })
|
||||||
@@ -324,20 +268,6 @@ appointmentGroupsRouter.delete("/:id", async (c) => {
|
|||||||
.where(eq(appointmentGroups.id, id));
|
.where(eq(appointmentGroups.id, id));
|
||||||
if (!group) return c.json({ error: "Not found" }, 404);
|
if (!group) return c.json({ error: "Not found" }, 404);
|
||||||
|
|
||||||
if (isGroomer) {
|
|
||||||
const groupAppts = await db
|
|
||||||
.select({ staffId: appointments.staffId, batherStaffId: appointments.batherStaffId })
|
|
||||||
.from(appointments)
|
|
||||||
.where(eq(appointments.groupId, id));
|
|
||||||
if (
|
|
||||||
!groupAppts.some(
|
|
||||||
(a) => a.staffId === staffRow.id || a.batherStaffId === staffRow.id
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return c.json({ error: "Forbidden" }, 403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set({ status: "cancelled", updatedAt: new Date() })
|
.set({ status: "cancelled", updatedAt: new Date() })
|
||||||
|
|||||||
@@ -41,10 +41,6 @@ const createAppointmentSchema = z.object({
|
|||||||
frequencyWeeks: z.number().int().min(1).max(52),
|
frequencyWeeks: z.number().int().min(1).max(52),
|
||||||
count: z.number().int().min(2).max(52),
|
count: z.number().int().min(2).max(52),
|
||||||
})
|
})
|
||||||
.refine(
|
|
||||||
(r) => r.frequencyWeeks * r.count <= 52,
|
|
||||||
{ message: "Recurrence series must not exceed 1 year" }
|
|
||||||
)
|
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -167,29 +163,6 @@ appointmentsRouter.post(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check batherStaffId conflicts if set
|
|
||||||
if (apptFields.batherStaffId) {
|
|
||||||
const conflicts = await tx
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
or(
|
|
||||||
eq(appointments.staffId, apptFields.batherStaffId),
|
|
||||||
eq(appointments.batherStaffId, apptFields.batherStaffId)
|
|
||||||
),
|
|
||||||
lt(appointments.startTime, end),
|
|
||||||
gte(appointments.endTime, start),
|
|
||||||
ne(appointments.status, "cancelled"),
|
|
||||||
ne(appointments.status, "no_show"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (conflicts.length > 0) {
|
|
||||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!recurrence) {
|
if (!recurrence) {
|
||||||
// Single appointment
|
// Single appointment
|
||||||
const [inserted] = await tx
|
const [inserted] = await tx
|
||||||
@@ -488,34 +461,6 @@ appointmentsRouter.patch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check batherStaffId conflicts if being updated or already set
|
|
||||||
const batherStaffId =
|
|
||||||
updateFields.batherStaffId !== undefined
|
|
||||||
? updateFields.batherStaffId
|
|
||||||
: current.batherStaffId;
|
|
||||||
if (batherStaffId) {
|
|
||||||
const conflicts = await tx
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
or(
|
|
||||||
eq(appointments.staffId, batherStaffId),
|
|
||||||
eq(appointments.batherStaffId, batherStaffId)
|
|
||||||
),
|
|
||||||
lt(appointments.startTime, end),
|
|
||||||
gte(appointments.endTime, start),
|
|
||||||
ne(appointments.status, "cancelled"),
|
|
||||||
ne(appointments.status, "no_show"),
|
|
||||||
ne(appointments.id, id),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (conflicts.length > 0) {
|
|
||||||
throw Object.assign(new Error("conflict"), { statusCode: 409 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [updated] = await tx
|
const [updated] = await tx
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set(update)
|
.set(update)
|
||||||
|
|||||||
+39
-53
@@ -102,10 +102,7 @@ bookRouter.get("/availability", async (c) => {
|
|||||||
|
|
||||||
const bookingSchema = z.object({
|
const bookingSchema = z.object({
|
||||||
serviceId: z.string().uuid(),
|
serviceId: z.string().uuid(),
|
||||||
startTime: z.string().datetime().refine(
|
startTime: z.string().datetime(),
|
||||||
(dt) => new Date(dt) > new Date(),
|
|
||||||
{ message: "Appointment must be in the future" }
|
|
||||||
),
|
|
||||||
clientName: z.string().min(1).max(200),
|
clientName: z.string().min(1).max(200),
|
||||||
clientEmail: z.string().email(),
|
clientEmail: z.string().email(),
|
||||||
clientPhone: z.string().max(50).optional(),
|
clientPhone: z.string().max(50).optional(),
|
||||||
@@ -258,39 +255,37 @@ bookRouter.get("/confirm/:token", async (c) => {
|
|||||||
const token = c.req.param("token");
|
const token = c.req.param("token");
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
|
// Atomic: consume token and confirm in a single query to prevent replay.
|
||||||
|
// Only future appointments can be confirmed.
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
|
||||||
.from(appointments)
|
|
||||||
.where(eq(appointments.confirmationToken, token))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!appt) {
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject if appointment is in the past
|
|
||||||
if (appt.startTime < new Date()) {
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Idempotent confirm: if already confirmed, redirect to success
|
|
||||||
if (appt.confirmationStatus === "confirmed") {
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject if already cancelled
|
|
||||||
if (appt.confirmationStatus === "cancelled") {
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set({
|
.set({
|
||||||
confirmationStatus: "confirmed",
|
confirmationStatus: "confirmed",
|
||||||
confirmedAt: new Date(),
|
confirmedAt: new Date(),
|
||||||
|
confirmationToken: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(appointments.id, appt.id));
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.confirmationToken, token),
|
||||||
|
eq(appointments.confirmationStatus, "pending"),
|
||||||
|
gt(appointments.startTime, new Date())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!appt) {
|
||||||
|
// Check status for idempotency: already-confirmed → redirect to confirmed
|
||||||
|
const [existing] = await db
|
||||||
|
.select({ confirmationStatus: appointments.confirmationStatus })
|
||||||
|
.from(appointments)
|
||||||
|
.where(eq(appointments.confirmationToken, token))
|
||||||
|
.limit(1);
|
||||||
|
if (existing?.confirmationStatus === "confirmed") {
|
||||||
|
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||||
|
}
|
||||||
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
|
}
|
||||||
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
return c.redirect(`${BASE_URL()}/booking/confirmed`);
|
||||||
});
|
});
|
||||||
@@ -302,29 +297,9 @@ bookRouter.get("/cancel/:token", async (c) => {
|
|||||||
const token = c.req.param("token");
|
const token = c.req.param("token");
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
|
// Atomic: consume token and cancel in a single query to prevent replay.
|
||||||
|
// Only future appointments can be cancelled.
|
||||||
const [appt] = await db
|
const [appt] = await db
|
||||||
.select()
|
|
||||||
.from(appointments)
|
|
||||||
.where(eq(appointments.confirmationToken, token))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!appt) {
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject if appointment is in the past
|
|
||||||
if (appt.startTime < new Date()) {
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reject if already cancelled (token was nullified — this path won't normally hit,
|
|
||||||
// but guard against edge cases where token lookup still works)
|
|
||||||
if (appt.confirmationStatus === "cancelled") {
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/error`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Single-use cancellation: nullify token after use
|
|
||||||
await db
|
|
||||||
.update(appointments)
|
.update(appointments)
|
||||||
.set({
|
.set({
|
||||||
confirmationStatus: "cancelled",
|
confirmationStatus: "cancelled",
|
||||||
@@ -332,7 +307,18 @@ bookRouter.get("/cancel/:token", async (c) => {
|
|||||||
confirmationToken: null,
|
confirmationToken: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(appointments.id, appt.id));
|
.where(
|
||||||
|
and(
|
||||||
|
eq(appointments.confirmationToken, token),
|
||||||
|
eq(appointments.confirmationStatus, "pending"),
|
||||||
|
gt(appointments.startTime, new Date())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!appt) {
|
||||||
|
return c.redirect(`${BASE_URL()}/booking/error`);
|
||||||
|
}
|
||||||
|
|
||||||
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
return c.redirect(`${BASE_URL()}/booking/cancelled`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
eq,
|
eq,
|
||||||
@@ -84,7 +84,12 @@ calendarRouter.get("/:staffId.ics", async (c) => {
|
|||||||
.where(eq(staff.id, staffId))
|
.where(eq(staff.id, staffId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!staffMember || staffMember.icalToken !== token) {
|
if (
|
||||||
|
!staffMember ||
|
||||||
|
!staffMember.icalToken ||
|
||||||
|
staffMember.icalToken.length !== token.length ||
|
||||||
|
!timingSafeEqual(Buffer.from(staffMember.icalToken), Buffer.from(token))
|
||||||
|
) {
|
||||||
return c.text("Unauthorized", 401);
|
return c.text("Unauthorized", 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { zValidator } from "@hono/zod-validator";
|
import { zValidator } from "@hono/zod-validator";
|
||||||
import { z } from "zod/v3";
|
import { z } from "zod/v3";
|
||||||
import { and, desc, eq, getDb, groomingVisitLogs, appointments, or } from "@groombook/db";
|
import { desc, eq, getDb, groomingVisitLogs } from "@groombook/db";
|
||||||
import type { AppEnv } from "../middleware/rbac.js";
|
|
||||||
|
|
||||||
export const groomingLogsRouter = new Hono<AppEnv>();
|
export const groomingLogsRouter = new Hono();
|
||||||
|
|
||||||
const createLogSchema = z.object({
|
const createLogSchema = z.object({
|
||||||
petId: z.string().uuid(),
|
petId: z.string().uuid(),
|
||||||
@@ -21,26 +20,6 @@ groomingLogsRouter.get("/", async (c) => {
|
|||||||
const db = getDb();
|
const db = getDb();
|
||||||
const petId = c.req.query("petId");
|
const petId = c.req.query("petId");
|
||||||
if (!petId) return c.json({ error: "petId is required" }, 400);
|
if (!petId) return c.json({ error: "petId is required" }, 400);
|
||||||
const staffRow = c.get("staff");
|
|
||||||
const isGroomer = staffRow?.role === "groomer";
|
|
||||||
|
|
||||||
if (isGroomer) {
|
|
||||||
const [appt] = await db
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(appointments.petId, petId),
|
|
||||||
or(
|
|
||||||
eq(appointments.staffId, staffRow.id),
|
|
||||||
eq(appointments.batherStaffId, staffRow.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(groomingVisitLogs)
|
.from(groomingVisitLogs)
|
||||||
@@ -54,50 +33,11 @@ groomingLogsRouter.post(
|
|||||||
zValidator("json", createLogSchema),
|
zValidator("json", createLogSchema),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const { groomedAt, petId, appointmentId, ...rest } = c.req.valid("json");
|
const { groomedAt, ...rest } = c.req.valid("json");
|
||||||
const staffRow = c.get("staff");
|
|
||||||
const isGroomer = staffRow?.role === "groomer";
|
|
||||||
|
|
||||||
if (isGroomer) {
|
|
||||||
if (appointmentId) {
|
|
||||||
const [appt] = await db
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(appointments.id, appointmentId),
|
|
||||||
or(
|
|
||||||
eq(appointments.staffId, staffRow.id),
|
|
||||||
eq(appointments.batherStaffId, staffRow.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
|
||||||
} else {
|
|
||||||
const [appt] = await db
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(appointments.petId, petId),
|
|
||||||
or(
|
|
||||||
eq(appointments.staffId, staffRow.id),
|
|
||||||
eq(appointments.batherStaffId, staffRow.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(groomingVisitLogs)
|
.insert(groomingVisitLogs)
|
||||||
.values({
|
.values({
|
||||||
...rest,
|
...rest,
|
||||||
petId,
|
|
||||||
appointmentId: appointmentId ?? null,
|
|
||||||
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
|
groomedAt: groomedAt ? new Date(groomedAt) : new Date(),
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -107,37 +47,10 @@ groomingLogsRouter.post(
|
|||||||
|
|
||||||
groomingLogsRouter.delete("/:id", async (c) => {
|
groomingLogsRouter.delete("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const id = c.req.param("id");
|
const [row] = await db
|
||||||
const staffRow = c.get("staff");
|
|
||||||
const isGroomer = staffRow?.role === "groomer";
|
|
||||||
|
|
||||||
const [log] = await db
|
|
||||||
.select()
|
|
||||||
.from(groomingVisitLogs)
|
|
||||||
.where(eq(groomingVisitLogs.id, id))
|
|
||||||
.limit(1);
|
|
||||||
if (!log) return c.json({ error: "Not found" }, 404);
|
|
||||||
|
|
||||||
if (isGroomer) {
|
|
||||||
const [appt] = await db
|
|
||||||
.select({ id: appointments.id })
|
|
||||||
.from(appointments)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(appointments.petId, log.petId),
|
|
||||||
or(
|
|
||||||
eq(appointments.staffId, staffRow.id),
|
|
||||||
eq(appointments.batherStaffId, staffRow.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
if (!appt) return c.json({ error: "Forbidden" }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(groomingVisitLogs)
|
.delete(groomingVisitLogs)
|
||||||
.where(eq(groomingVisitLogs.id, id))
|
.where(eq(groomingVisitLogs.id, c.req.param("id")))
|
||||||
.returning();
|
.returning();
|
||||||
|
if (!row) return c.json({ error: "Not found" }, 404);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,61 +44,53 @@ const updateInvoiceSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// List invoices
|
// List invoices
|
||||||
const listInvoicesQuerySchema = z.object({
|
invoicesRouter.get("/", async (c) => {
|
||||||
clientId: z.string().uuid().optional(),
|
const db = getDb();
|
||||||
appointmentId: z.string().uuid().optional(),
|
const clientId = c.req.query("clientId");
|
||||||
status: z.enum(["draft", "pending", "paid", "void"]).optional(),
|
const appointmentId = c.req.query("appointmentId");
|
||||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
const status = c.req.query("status");
|
||||||
offset: z.coerce.number().int().min(0).default(0),
|
const limit = Math.min(parseInt(c.req.query("limit") || "50", 10), 200);
|
||||||
|
const offset = parseInt(c.req.query("offset") || "0", 10);
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
if (clientId) conditions.push(eq(invoices.clientId, clientId));
|
||||||
|
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
|
||||||
|
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
|
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ count: sql<number>`count(*)` })
|
||||||
|
.from(invoices)
|
||||||
|
.where(whereClause);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: invoices.id,
|
||||||
|
appointmentId: invoices.appointmentId,
|
||||||
|
clientId: invoices.clientId,
|
||||||
|
clientName: clients.name,
|
||||||
|
subtotalCents: invoices.subtotalCents,
|
||||||
|
taxCents: invoices.taxCents,
|
||||||
|
tipCents: invoices.tipCents,
|
||||||
|
totalCents: invoices.totalCents,
|
||||||
|
status: invoices.status,
|
||||||
|
paymentMethod: invoices.paymentMethod,
|
||||||
|
paidAt: invoices.paidAt,
|
||||||
|
notes: invoices.notes,
|
||||||
|
createdAt: invoices.createdAt,
|
||||||
|
updatedAt: invoices.updatedAt,
|
||||||
|
})
|
||||||
|
.from(invoices)
|
||||||
|
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
||||||
|
.where(whereClause)
|
||||||
|
.orderBy(invoices.createdAt)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return c.json({ data: rows, total: totalResult?.count ?? 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
invoicesRouter.get(
|
|
||||||
"/",
|
|
||||||
zValidator("query", listInvoicesQuerySchema),
|
|
||||||
async (c) => {
|
|
||||||
const db = getDb();
|
|
||||||
const { clientId, appointmentId, status, limit, offset } = c.req.valid("query");
|
|
||||||
|
|
||||||
const conditions = [];
|
|
||||||
if (clientId) conditions.push(eq(invoices.clientId, clientId));
|
|
||||||
if (appointmentId) conditions.push(eq(invoices.appointmentId, appointmentId));
|
|
||||||
if (status) conditions.push(eq(invoices.status, status as "draft" | "pending" | "paid" | "void"));
|
|
||||||
|
|
||||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
|
||||||
|
|
||||||
const [totalResult] = await db
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(invoices)
|
|
||||||
.where(whereClause);
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
id: invoices.id,
|
|
||||||
appointmentId: invoices.appointmentId,
|
|
||||||
clientId: invoices.clientId,
|
|
||||||
clientName: clients.name,
|
|
||||||
subtotalCents: invoices.subtotalCents,
|
|
||||||
taxCents: invoices.taxCents,
|
|
||||||
tipCents: invoices.tipCents,
|
|
||||||
totalCents: invoices.totalCents,
|
|
||||||
status: invoices.status,
|
|
||||||
paymentMethod: invoices.paymentMethod,
|
|
||||||
paidAt: invoices.paidAt,
|
|
||||||
notes: invoices.notes,
|
|
||||||
createdAt: invoices.createdAt,
|
|
||||||
updatedAt: invoices.updatedAt,
|
|
||||||
})
|
|
||||||
.from(invoices)
|
|
||||||
.leftJoin(clients, eq(invoices.clientId, clients.id))
|
|
||||||
.where(whereClause)
|
|
||||||
.orderBy(invoices.createdAt)
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
return c.json({ data: rows, total: totalResult?.count ?? 0 });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get single invoice with line items and tip splits
|
// Get single invoice with line items and tip splits
|
||||||
invoicesRouter.get("/:id", async (c) => {
|
invoicesRouter.get("/:id", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const createServiceSchema = z.object({
|
|||||||
name: z.string().min(1).max(200),
|
name: z.string().min(1).max(200),
|
||||||
description: z.string().max(2000).optional(),
|
description: z.string().max(2000).optional(),
|
||||||
basePriceCents: z.number().int().positive(),
|
basePriceCents: z.number().int().positive(),
|
||||||
durationMinutes: z.number().int().positive().max(480),
|
durationMinutes: z.number().int().positive(),
|
||||||
active: z.boolean().default(true),
|
active: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,25 @@ import type { AppEnv } from "../middleware/rbac.js";
|
|||||||
|
|
||||||
export const setupRouter = new Hono<AppEnv>();
|
export const setupRouter = new Hono<AppEnv>();
|
||||||
|
|
||||||
|
// Simple in-memory rate limiter: 10 req/min per IP for setup endpoints
|
||||||
|
const setupRateLimitMap = new Map<string, { count: number; resetAt: number }>();
|
||||||
|
const SETUP_RATE_LIMIT = 10;
|
||||||
|
const SETUP_RATE_WINDOW_MS = 60 * 1000;
|
||||||
|
|
||||||
|
function checkSetupRateLimit(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = setupRateLimitMap.get(ip);
|
||||||
|
if (!entry || now > entry.resetAt) {
|
||||||
|
setupRateLimitMap.set(ip, { count: 1, resetAt: now + SETUP_RATE_WINDOW_MS });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (entry.count >= SETUP_RATE_LIMIT) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
entry.count++;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
// GET /api/setup/status — public (no auth), returns whether setup is needed
|
||||||
// and whether the auth provider bootstrap step should be shown
|
// and whether the auth provider bootstrap step should be shown
|
||||||
setupRouter.get("/status", async (c) => {
|
setupRouter.get("/status", async (c) => {
|
||||||
@@ -185,6 +204,11 @@ const authProviderTestSchema = z.object({
|
|||||||
* After setup completes, this endpoint permanently returns 403.
|
* After setup completes, this endpoint permanently returns 403.
|
||||||
*/
|
*/
|
||||||
setupRouter.post("/auth-provider", async (c) => {
|
setupRouter.post("/auth-provider", async (c) => {
|
||||||
|
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||||
|
if (!checkSetupRateLimit(ip)) {
|
||||||
|
return c.json({ error: "Too many requests. Please try again later." }, 429);
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
// Guard: only allow during fresh install (no super user yet)
|
// Guard: only allow during fresh install (no super user yet)
|
||||||
@@ -254,6 +278,11 @@ setupRouter.post("/auth-provider", async (c) => {
|
|||||||
* Only available when needsSetup is true (no super user = fresh install).
|
* Only available when needsSetup is true (no super user = fresh install).
|
||||||
*/
|
*/
|
||||||
setupRouter.post("/auth-provider/test", async (c) => {
|
setupRouter.post("/auth-provider/test", async (c) => {
|
||||||
|
const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||||
|
if (!checkSetupRateLimit(ip)) {
|
||||||
|
return c.json({ ok: false, error: "Too many requests. Please try again later." }, 429);
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
// Guard: only allow during fresh install (no super user yet)
|
// Guard: only allow during fresh install (no super user yet)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { z } from "zod/v3";
|
|
||||||
import { eq, getDb, invoices } from "@groombook/db";
|
import { eq, getDb, invoices } from "@groombook/db";
|
||||||
import { getStripeClient } from "../services/payment.js";
|
import { getStripeClient } from "../services/payment.js";
|
||||||
|
|
||||||
@@ -45,13 +44,10 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||||
for (const invoiceId of invoiceIds) {
|
for (const invoiceId of invoiceIds) {
|
||||||
if (!invoiceId) continue;
|
if (!invoiceId) continue;
|
||||||
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
|
||||||
if (!parsed.success) continue;
|
|
||||||
const invoiceIdTrimmed = invoiceId.trim();
|
|
||||||
const [inv] = await db
|
const [inv] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(eq(invoices.id, invoiceIdTrimmed))
|
.where(eq(invoices.id, invoiceId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!inv) continue;
|
if (!inv) continue;
|
||||||
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
if (inv.stripePaymentIntentId && inv.stripePaymentIntentId !== pi.id) continue;
|
||||||
@@ -64,7 +60,7 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
stripePaymentIntentId: pi.id,
|
stripePaymentIntentId: pi.id,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(invoices.id, invoiceIdTrimmed));
|
.where(eq(invoices.id, invoiceId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === "payment_intent.payment_failed") {
|
} else if (event.type === "payment_intent.payment_failed") {
|
||||||
@@ -73,16 +69,13 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
const invoiceIds = pi.metadata.groombook_invoice_ids.split(",");
|
||||||
for (const invoiceId of invoiceIds) {
|
for (const invoiceId of invoiceIds) {
|
||||||
if (!invoiceId) continue;
|
if (!invoiceId) continue;
|
||||||
const parsed = z.string().uuid().safeParse(invoiceId.trim());
|
|
||||||
if (!parsed.success) continue;
|
|
||||||
const invoiceIdTrimmed = invoiceId.trim();
|
|
||||||
await db
|
await db
|
||||||
.update(invoices)
|
.update(invoices)
|
||||||
.set({
|
.set({
|
||||||
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
paymentFailureReason: pi.last_payment_error?.message ?? "Payment failed",
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(invoices.id, invoiceIdTrimmed));
|
.where(eq(invoices.id, invoiceId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === "charge.refunded") {
|
} else if (event.type === "charge.refunded") {
|
||||||
|
|||||||
@@ -3,10 +3,22 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|
||||||
# Cache static assets
|
# Cache static assets
|
||||||
location ~* \.(js|css|png|svg|ico|woff2)$ {
|
location ~* \.(js|css|png|svg|ico|woff2)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Proxy API calls to the API service
|
# Proxy API calls to the API service
|
||||||
|
|||||||
Reference in New Issue
Block a user