fix(coach): enforce the module gate on the about-me routes

The structured self-context endpoints — about-me GET/PUT, the adopt
POST, and the clarifying-questions GET/DELETE — read and wrote Coach
self-context with no module check. Each handler now runs the two-layer
requireModuleEnabled(userId, "coach") gate right after auth, returning
403 module.disabled when the operator availability layer or the
per-user disableCoach opt-out has Coach turned off. The adopt route
test mocks the gate to stay focused on the locked read-modify-write.
This commit is contained in:
Marc-André Bombeck committed 2026-06-16 01:25:51 +02:00
1 parent 2b5974cf4a
commit 064ed92071
4 files changed
+21

No files matched your search

@@ -43,6 +43,9 @@ vi.mock("@/lib/ai/coach/bytes-codec", () => ({
vi.mock("@/lib/ai/coach/about-me", () => ({
getSelfContextForUser: vi.fn(),
}));
vi.mock("@/lib/modules/gate", () => ({
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
}));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
@@ -41,6 +41,7 @@ import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { getSelfContextForUser } from "@/lib/ai/coach/about-me";
import { requireModuleEnabled } from "@/lib/modules/gate";
import {
ABOUT_ME_FIELD_MAX_CHARS,
ABOUT_ME_MAX_CHARS,
@@ -93,6 +94,9 @@ function normalise(s: string): string {
export const POST = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
// v1.18.0 — Coach module gate (operator availability + disableCoach).
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`coach-about-me:adopt:${user.id}`,
@@ -24,6 +24,7 @@ import {
PENDING_QUESTION_MAX_CHARS,
setPendingQuestionsForUser,
} from "@/lib/ai/coach/about-me";
import { requireModuleEnabled } from "@/lib/modules/gate";
const dismissSchema = z.object({
/** Exact question text to dismiss. Omitted = dismiss all. */
@@ -32,6 +33,9 @@ const dismissSchema = z.object({
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.18.0 — Coach module gate (operator availability + disableCoach).
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const questions = await getPendingQuestionsForUser(user.id);
annotate({
action: { name: "coach.about_me.questions.get" },
@@ -42,6 +46,9 @@ export const GET = apiHandler(async () => {
export const DELETE = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
// v1.18.0 — Coach module gate (operator availability + disableCoach).
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const { data: body, error: jsonError } = await safeJson(req, {
maxBytes: 4 * 1024,
+7
View File
@@ -43,6 +43,7 @@ import {
setPendingQuestionsForUser,
} from "@/lib/ai/coach/about-me";
import { deriveClarifyingQuestions } from "@/lib/ai/coach/self-context-questions";
import { requireModuleEnabled } from "@/lib/modules/gate";
import {
ABOUT_ME_FIELD_MAX_CHARS,
ABOUT_ME_MAX_CHARS,
@@ -54,6 +55,9 @@ const PUT_WINDOW_MS = 60_000;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
// v1.18.0 — Coach module gate (operator availability + disableCoach).
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const [ctx, pendingQuestions, row] = await Promise.all([
getSelfContextForUser(user.id),
@@ -89,6 +93,9 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
// v1.18.0 — Coach module gate (operator availability + disableCoach).
const gate = await requireModuleEnabled(user.id, "coach");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`coach-about-me:put:${user.id}`,