Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f97a19cdd | |||
| a407f866d5 | |||
| 04147f3e6c | |||
| dc947874ca | |||
| 78b71cca58 | |||
| 5456637705 | |||
| 3037b77fe8 | |||
| ae873215c0 | |||
| 9d37053580 | |||
| fdaf4db0d5 |
@@ -0,0 +1,27 @@
|
|||||||
|
# The current version of the config schema
|
||||||
|
version: 1
|
||||||
|
# What protocol to use when performing git operations. Supported values: ssh, https
|
||||||
|
git_protocol: https
|
||||||
|
# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.
|
||||||
|
editor:
|
||||||
|
# When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
|
||||||
|
prompt: enabled
|
||||||
|
# Preference for editor-based interactive prompting. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
|
||||||
|
prefer_editor_prompt: disabled
|
||||||
|
# A pager program to send command output to, e.g. "less". If blank, will refer to environment. Set the value to "cat" to disable the pager.
|
||||||
|
pager:
|
||||||
|
# Aliases allow you to create nicknames for gh commands
|
||||||
|
aliases:
|
||||||
|
co: pr checkout
|
||||||
|
# The path to a unix socket through which to send HTTP connections. If blank, HTTP traffic will be handled by net/http.DefaultTransport.
|
||||||
|
http_unix_socket:
|
||||||
|
# What web browser gh should use when opening URLs. If blank, will refer to environment.
|
||||||
|
browser:
|
||||||
|
# Whether to display labels using their RGB hex color codes in terminals that support truecolor. Supported values: enabled, disabled
|
||||||
|
color_labels: disabled
|
||||||
|
# Whether customizable, 4-bit accessible colors should be used. Supported values: enabled, disabled
|
||||||
|
accessible_colors: disabled
|
||||||
|
# Whether an accessible prompter should be used. Supported values: enabled, disabled
|
||||||
|
accessible_prompter: disabled
|
||||||
|
# Whether to use a animated spinner as a progress indicator. If disabled, a textual progress indicator is used instead. Supported values: enabled, disabled
|
||||||
|
spinner: enabled
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
github.com:
|
||||||
|
users:
|
||||||
|
groombook-engineer[bot]:
|
||||||
|
oauth_token: ghs_znRlNnhuSsNZp0GejabxpkSUqXC9vt27yl3K
|
||||||
|
user: groombook-engineer[bot]
|
||||||
|
oauth_token: ghs_znRlNnhuSsNZp0GejabxpkSUqXC9vt27yl3K
|
||||||
@@ -14,29 +14,7 @@ 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,7 +12,6 @@ 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 & { confirmationToken: string }) | null = BASE_APPT as typeof BASE_APPT & { confirmationToken: string };
|
let mockAppt: typeof BASE_APPT | null = BASE_APPT;
|
||||||
let lastUpdate: Record<string, unknown> = {};
|
let lastUpdate: Record<string, unknown> = {};
|
||||||
|
|
||||||
function resetMock() {
|
function resetMock() {
|
||||||
mockAppt = { ...BASE_APPT, confirmationToken: "valid-token-abc123" } as typeof BASE_APPT & { confirmationToken: string };
|
mockAppt = { ...BASE_APPT };
|
||||||
lastUpdate = {};
|
lastUpdate = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,39 +55,19 @@ vi.mock("@groombook/db", () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
update: () => ({
|
update: () => ({
|
||||||
set: (vals: Record<string, unknown>) => {
|
set: (vals: Record<string, unknown>) => ({
|
||||||
const setVals = vals;
|
where: () => {
|
||||||
return {
|
lastUpdate = { ...vals };
|
||||||
where: () => {
|
if (mockAppt) {
|
||||||
const preUpdate = mockAppt ? { ...mockAppt } : null;
|
mockAppt = { ...mockAppt, ...vals } as typeof BASE_APPT;
|
||||||
const preStatus = preUpdate?.confirmationStatus;
|
}
|
||||||
const preStart = preUpdate?.startTime;
|
return { returning: () => (mockAppt ? [mockAppt] : []) };
|
||||||
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(/role 'receptionist' is not permitted/i);
|
expect(body.error).toMatch(/super user privileges required/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(/role 'groomer' is not permitted/i);
|
expect(body.error).toMatch(/super user privileges required/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allows a manager with multiple allowed roles", async () => {
|
it("allows a manager with multiple allowed roles", async () => {
|
||||||
|
|||||||
@@ -42,23 +42,6 @@ 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" }));
|
||||||
|
|
||||||
|
|||||||
+11
-35
@@ -86,15 +86,10 @@ 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,
|
secret: BETTER_AUTH_SECRET ?? "placeholder-secret-do-not-use-in-prod",
|
||||||
baseURL: BETTER_AUTH_URL,
|
baseURL: BETTER_AUTH_URL,
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -204,36 +199,20 @@ 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) {
|
||||||
const validAuthz = validateIssuerHost(authzUrl, providerConfig.issuerUrl);
|
oidcConfig = {
|
||||||
const validToken = validateIssuerHost(tokenUrl, providerConfig.issuerUrl);
|
authorizationUrl: authzUrl,
|
||||||
const validUserInfo = validateIssuerHost(userInfoUrl, providerConfig.issuerUrl);
|
tokenUrl: providerConfig.internalBaseUrl
|
||||||
if (!validAuthz || !validToken || !validUserInfo) {
|
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
|
||||||
console.warn("[auth] OIDC discovery URL host mismatch — possible redirection attack, rejecting");
|
: tokenUrl,
|
||||||
} else {
|
userInfoUrl: providerConfig.internalBaseUrl
|
||||||
oidcConfig = {
|
? replaceHost(userInfoUrl, providerConfig.internalBaseUrl)
|
||||||
authorizationUrl: authzUrl,
|
: userInfoUrl,
|
||||||
tokenUrl: providerConfig.internalBaseUrl
|
};
|
||||||
? replaceHost(tokenUrl, providerConfig.internalBaseUrl)
|
console.log("[auth] OIDC discovery successful, provider:", providerConfig.providerId);
|
||||||
: 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");
|
||||||
}
|
}
|
||||||
@@ -308,9 +287,6 @@ 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: hasAllowedRole
|
error: staffRow.isSuperUser
|
||||||
? "Forbidden: super user privileges required"
|
? `Forbidden: role '${staffRow.role}' is not permitted`
|
||||||
: `Forbidden: role '${staffRow.role}' is not permitted`,
|
: "Forbidden: super user privileges required",
|
||||||
},
|
},
|
||||||
403
|
403
|
||||||
);
|
);
|
||||||
|
|||||||
+49
-38
@@ -255,37 +255,39 @@ 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(
|
.where(eq(appointments.id, appt.id));
|
||||||
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`);
|
||||||
});
|
});
|
||||||
@@ -297,9 +299,29 @@ 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",
|
||||||
@@ -307,18 +329,7 @@ bookRouter.get("/cancel/:token", async (c) => {
|
|||||||
confirmationToken: null,
|
confirmationToken: null,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(
|
.where(eq(appointments.id, appt.id));
|
||||||
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, timingSafeEqual } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
eq,
|
eq,
|
||||||
@@ -84,12 +84,7 @@ calendarRouter.get("/:staffId.ics", async (c) => {
|
|||||||
.where(eq(staff.id, staffId))
|
.where(eq(staff.id, staffId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (
|
if (!staffMember || staffMember.icalToken !== token) {
|
||||||
!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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -462,9 +462,45 @@ import {
|
|||||||
detachPaymentMethod,
|
detachPaymentMethod,
|
||||||
createSetupIntent,
|
createSetupIntent,
|
||||||
getOrCreateStripeCustomer,
|
getOrCreateStripeCustomer,
|
||||||
getStripeClient,
|
|
||||||
} from "../services/payment.js";
|
} from "../services/payment.js";
|
||||||
|
|
||||||
|
const payInvoiceSchema = z.object({
|
||||||
|
invoiceId: z.string().uuid(),
|
||||||
|
});
|
||||||
|
|
||||||
|
portalRouter.post(
|
||||||
|
"/invoices/:id/pay",
|
||||||
|
zValidator("json", payInvoiceSchema),
|
||||||
|
async (c) => {
|
||||||
|
const db = getDb();
|
||||||
|
const invoiceId = c.req.param("id");
|
||||||
|
const sessionId = c.req.header("X-Impersonation-Session-Id");
|
||||||
|
const clientId = await getClientIdFromSession(sessionId);
|
||||||
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
|
const [invoice] = await db
|
||||||
|
.select()
|
||||||
|
.from(invoices)
|
||||||
|
.where(eq(invoices.id, invoiceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!invoice) return c.json({ error: "Not found" }, 404);
|
||||||
|
if (invoice.clientId !== clientId) return c.json({ error: "Forbidden" }, 403);
|
||||||
|
if (invoice.status === "draft" || invoice.status === "void") {
|
||||||
|
return c.json({ error: "Cannot pay a draft or void invoice" }, 422);
|
||||||
|
}
|
||||||
|
if (invoice.status === "paid") {
|
||||||
|
return c.json({ error: "Invoice is already paid" }, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY ?? "";
|
||||||
|
const result = await createPaymentIntent(invoiceId, clientId);
|
||||||
|
if (!result) return c.json({ error: "Payment service unavailable" }, 503);
|
||||||
|
|
||||||
|
return c.json({ clientSecret: result.clientSecret, publishableKey: stripePublishableKey });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const payMultipleSchema = z.object({
|
const payMultipleSchema = z.object({
|
||||||
invoiceIds: z.array(z.string().uuid()).min(1),
|
invoiceIds: z.array(z.string().uuid()).min(1),
|
||||||
});
|
});
|
||||||
@@ -544,23 +580,19 @@ portalRouter.delete("/payment-methods/:id", async (c) => {
|
|||||||
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
if (!clientId) return c.json({ error: "Unauthorized" }, 401);
|
||||||
|
|
||||||
const paymentMethodId = c.req.param("id");
|
const paymentMethodId = c.req.param("id");
|
||||||
|
|
||||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
|
||||||
if (!stripeCustomerId) return c.json({ error: "No payment method found" }, 404);
|
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
|
||||||
if (!stripe) return c.json({ error: "Payment service unavailable" }, 503);
|
|
||||||
|
|
||||||
const paymentMethod = await stripe.paymentMethods.retrieve(paymentMethodId);
|
|
||||||
if (!paymentMethod || paymentMethod.customer !== stripeCustomerId) {
|
|
||||||
return c.json({ error: "Payment method not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = await detachPaymentMethod(paymentMethodId);
|
const ok = await detachPaymentMethod(paymentMethodId);
|
||||||
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
if (!ok) return c.json({ error: "Failed to detach payment method" }, 500);
|
||||||
return c.json({ ok: true });
|
return c.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Config endpoint ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
portalRouter.get("/config", (c) => {
|
||||||
|
return c.json({
|
||||||
|
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY ?? "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Dev-mode session creation ──────────────────────────────────────────────
|
// ─── Dev-mode session creation ──────────────────────────────────────────────
|
||||||
// Allows the dev login selector to vend an impersonation session for a client
|
// Allows the dev login selector to vend an impersonation session for a client
|
||||||
// without requiring manager auth. Only available when AUTH_DISABLED=true.
|
// without requiring manager auth. Only available when AUTH_DISABLED=true.
|
||||||
|
|||||||
@@ -6,25 +6,6 @@ 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) => {
|
||||||
@@ -204,11 +185,6 @@ 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)
|
||||||
@@ -278,11 +254,6 @@ 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,13 +1,12 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { eq, getDb, invoices } from "@groombook/db";
|
import { eq, getDb, invoices } from "@groombook/db";
|
||||||
import { getStripeClient } from "../services/payment.js";
|
|
||||||
|
|
||||||
export const webhooksRouter = new Hono();
|
export const webhooksRouter = new Hono();
|
||||||
|
|
||||||
webhooksRouter.post("/stripe", async (c) => {
|
webhooksRouter.post("/stripe", async (c) => {
|
||||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
const secret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
if (!webhookSecret) {
|
if (!secret) {
|
||||||
return c.json({ error: "Webhook secret not configured" }, 503);
|
return c.json({ error: "Webhook secret not configured" }, 503);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,14 +22,11 @@ webhooksRouter.post("/stripe", async (c) => {
|
|||||||
return c.json({ error: "Could not read body" }, 400);
|
return c.json({ error: "Could not read body" }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripe = getStripeClient();
|
const stripe = new Stripe(secret, { apiVersion: "2026-03-25.dahlia" });
|
||||||
if (!stripe) {
|
|
||||||
return c.json({ error: "Stripe not configured" }, 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
let event: Stripe.Event;
|
let event: Stripe.Event;
|
||||||
try {
|
try {
|
||||||
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
event = stripe.webhooks.constructEvent(rawBody, signature, secret);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Invalid signature";
|
const message = err instanceof Error ? err.message : "Invalid signature";
|
||||||
return c.json({ error: message }, 401);
|
return c.json({ error: message }, 401);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { getDb, clients, eq, inArray, invoices } from "@groombook/db";
|
import { getDb, clients, eq, invoices } from "@groombook/db";
|
||||||
|
|
||||||
let _stripe: Stripe | null | undefined;
|
let _stripe: Stripe | null | undefined;
|
||||||
|
|
||||||
export function getStripeClient(): Stripe | null {
|
function getStripeClient(): Stripe | null {
|
||||||
if (_stripe === undefined) {
|
if (_stripe === undefined) {
|
||||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||||
if (!secretKey) return null;
|
if (!secretKey) return null;
|
||||||
@@ -59,8 +59,8 @@ export async function createPaymentIntent(
|
|||||||
const allInvoices = await db
|
const allInvoices = await db
|
||||||
.select({ totalCents: invoices.totalCents })
|
.select({ totalCents: invoices.totalCents })
|
||||||
.from(invoices)
|
.from(invoices)
|
||||||
.where(inArray(invoices.id, invoiceIds));
|
.where(eq(invoices.id, firstInvoiceId));
|
||||||
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, 0);
|
totalCents = allInvoices.reduce((sum, inv) => sum + inv.totalCents, totalCents);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
const stripeCustomerId = await getOrCreateStripeCustomer(clientId);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
eq,
|
eq,
|
||||||
getDb,
|
getDb,
|
||||||
gte,
|
gte,
|
||||||
|
inArray,
|
||||||
lt,
|
lt,
|
||||||
appointments,
|
appointments,
|
||||||
clients,
|
clients,
|
||||||
@@ -64,56 +65,66 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const appt of upcoming) {
|
const appointmentIds: string[] = upcoming.map((a) => a.id as string);
|
||||||
// Check if reminder already sent (unique constraint prevents double-send)
|
|
||||||
const existing = await db
|
if (appointmentIds.length === 0) continue;
|
||||||
.select({ id: reminderLogs.id })
|
|
||||||
.from(reminderLogs)
|
const sentAppointmentIds = new Set(
|
||||||
.where(
|
(
|
||||||
and(
|
await db
|
||||||
eq(reminderLogs.appointmentId, appt.id),
|
.select({ appointmentId: reminderLogs.appointmentId })
|
||||||
eq(reminderLogs.reminderType, window.label)
|
.from(reminderLogs)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(reminderLogs.reminderType, window.label),
|
||||||
|
inArray(reminderLogs.appointmentId, appointmentIds)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
).map((r) => r.appointmentId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const joinedRows = await db
|
||||||
|
.select({
|
||||||
|
appointmentId: appointments.id,
|
||||||
|
startTime: appointments.startTime,
|
||||||
|
clientId: appointments.clientId,
|
||||||
|
petId: appointments.petId,
|
||||||
|
serviceId: appointments.serviceId,
|
||||||
|
staffId: appointments.staffId,
|
||||||
|
confirmationToken: appointments.confirmationToken,
|
||||||
|
clientName: clients.name,
|
||||||
|
clientEmail: clients.email,
|
||||||
|
clientEmailOptOut: clients.emailOptOut,
|
||||||
|
petName: pets.name,
|
||||||
|
serviceName: services.name,
|
||||||
|
staffName: staff.name,
|
||||||
|
})
|
||||||
|
.from(appointments)
|
||||||
|
.innerJoin(clients, eq(appointments.clientId, clients.id))
|
||||||
|
.innerJoin(pets, eq(appointments.petId, pets.id))
|
||||||
|
.innerJoin(services, eq(appointments.serviceId, services.id))
|
||||||
|
.leftJoin(staff, eq(appointments.staffId, staff.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
gte(appointments.startTime, windowStart),
|
||||||
|
lt(appointments.startTime, windowEnd),
|
||||||
|
eq(appointments.status, "scheduled")
|
||||||
)
|
)
|
||||||
.limit(1);
|
);
|
||||||
|
|
||||||
if (existing.length > 0) continue; // already sent
|
const appointmentMap = new Map<string, typeof joinedRows[number]>();
|
||||||
|
for (const row of joinedRows) {
|
||||||
|
appointmentMap.set(row.appointmentId, row);
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch related records for the email
|
for (const appt of upcoming) {
|
||||||
const [client] = await db
|
if (sentAppointmentIds.has(appt.id)) continue;
|
||||||
.select({ name: clients.name, email: clients.email, emailOptOut: clients.emailOptOut })
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.id, appt.clientId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!client || !client.email || client.emailOptOut) continue;
|
const row = appointmentMap.get(appt.id);
|
||||||
|
if (!row) continue;
|
||||||
|
if (!row.clientEmail || row.clientEmailOptOut) continue;
|
||||||
|
if (!row.petName || !row.serviceName) continue;
|
||||||
|
|
||||||
const [pet] = await db
|
|
||||||
.select({ name: pets.name })
|
|
||||||
.from(pets)
|
|
||||||
.where(eq(pets.id, appt.petId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const [service] = await db
|
|
||||||
.select({ name: services.name })
|
|
||||||
.from(services)
|
|
||||||
.where(eq(services.id, appt.serviceId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let groomerName: string | null = null;
|
|
||||||
if (appt.staffId) {
|
|
||||||
const [groomer] = await db
|
|
||||||
.select({ name: staff.name })
|
|
||||||
.from(staff)
|
|
||||||
.where(eq(staff.id, appt.staffId))
|
|
||||||
.limit(1);
|
|
||||||
groomerName = groomer?.name ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!pet || !service) continue;
|
|
||||||
|
|
||||||
// Ensure the appointment has a confirmation token before sending the reminder.
|
|
||||||
// Generate one if it doesn't have one yet (e.g. pre-existing appointments).
|
|
||||||
let confirmationToken = appt.confirmationToken;
|
let confirmationToken = appt.confirmationToken;
|
||||||
if (!confirmationToken) {
|
if (!confirmationToken) {
|
||||||
confirmationToken = randomBytes(32).toString("hex");
|
confirmationToken = randomBytes(32).toString("hex");
|
||||||
@@ -125,12 +136,12 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
|
|
||||||
const sent = await sendEmail(
|
const sent = await sendEmail(
|
||||||
buildReminderEmail(
|
buildReminderEmail(
|
||||||
client.email,
|
row.clientEmail,
|
||||||
{
|
{
|
||||||
clientName: client.name,
|
clientName: row.clientName,
|
||||||
petName: pet.name,
|
petName: row.petName,
|
||||||
serviceName: service.name,
|
serviceName: row.serviceName,
|
||||||
groomerName,
|
groomerName: row.staffName ?? null,
|
||||||
startTime: appt.startTime,
|
startTime: appt.startTime,
|
||||||
},
|
},
|
||||||
window.hours,
|
window.hours,
|
||||||
@@ -139,7 +150,6 @@ export async function runReminderCheck(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (sent) {
|
if (sent) {
|
||||||
// Record send — ignore conflicts (race condition between instances)
|
|
||||||
await db
|
await db
|
||||||
.insert(reminderLogs)
|
.insert(reminderLogs)
|
||||||
.values({ appointmentId: appt.id, reminderType: window.label })
|
.values({ appointmentId: appt.id, reminderType: window.label })
|
||||||
@@ -172,3 +182,4 @@ export async function runSessionCleanup(): Promise<void> {
|
|||||||
.delete(session)
|
.delete(session)
|
||||||
.where(lt(session.expiresAt, now));
|
.where(lt(session.expiresAt, now));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,22 +3,10 @@ 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
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ export function CustomerPortal() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showReschedule && rescheduleAppointment && (
|
{showReschedule && rescheduleAppointment && (
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
<RescheduleFlow
|
<RescheduleFlow
|
||||||
appointment={rescheduleAppointment as any}
|
appointment={rescheduleAppointment as any}
|
||||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||||
|
|||||||
+1
-1
Submodule infra updated: b667a3f005...d6c0d13d02
Submodule
+1
Submodule infra-repo added at ff42966751
Reference in New Issue
Block a user