mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
A managed profile could be created and deleted and never changed. A name typed as a placeholder was permanent, a household that moved kept the timezone the record was created in, and there was no way to correct a date of birth somebody finally had (#939). PATCH /api/managed-profiles/{id} takes the fields creation takes, every one optional and at least one required — an empty body is a refusal rather than a save that audits a change nobody made. It carries the gate the rest of the family carries: a cookie session with a fresh second factor, an active Guardian grant on the record, and the same byte-identical 404 for "no such record" and "not yours". A GET beside it answers the record as it stands, because the account payload names a managed record and nothing else, and a form filled from that would have had blank fields to send. The new field is gender. Leaving it out of v1.37.0 meant every managed record started with the column NULL, which is what the cycle module derives its default from: a guardian creating a record for a child could neither record the answer nor turn the module off from there. It reuses the column and the three values every other surface writes, so there is one word for one column rather than two. Refs #939
83 lines
3.0 KiB
TypeScript
83 lines
3.0 KiB
TypeScript
import { NextRequest } from "next/server";
|
|
|
|
import {
|
|
apiHandler,
|
|
MFA_STEP_UP_MAX_AGE_SECONDS,
|
|
requireFreshMfa,
|
|
} from "@/lib/api-handler";
|
|
import {
|
|
apiError,
|
|
apiSuccess,
|
|
returnAllZodIssues,
|
|
safeJson,
|
|
} from "@/lib/api-response";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import { createManagedProfile } from "@/lib/managed-profiles/create";
|
|
import { toManagedProfileView } from "@/lib/managed-profiles/lifecycle";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
import { createManagedProfileSchema } from "@/lib/validations/managed-profiles";
|
|
|
|
/**
|
|
* The same ceiling its guardian sibling carries, and for the same reason.
|
|
*
|
|
* `POST /api/managed-profiles/{id}/guardians` has been rate-limited at ten an
|
|
* hour since it shipped; this route creates a real account row on every call
|
|
* and had none. A person cannot mistype their way into a hundred accounts, but
|
|
* a script can, and the two endpoints of one family answering differently to
|
|
* the same abuse is the kind of asymmetry nobody notices until it is used.
|
|
*/
|
|
const CREATE_LIMIT = 10;
|
|
const CREATE_WINDOW_MS = 60 * 60 * 1000;
|
|
|
|
/**
|
|
* Create a record a Guardian administers. This deliberately uses the
|
|
* cookie-only fresh-MFA gate: a Bearer token cannot mint a credential-less
|
|
* person and persistent management relationship.
|
|
*/
|
|
export const POST = apiHandler(async (request: NextRequest) => {
|
|
const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS);
|
|
// Before the body is read, exactly as the guardian route does it: the cheap
|
|
// refusal comes first so a flood costs a bucket lookup rather than a parse.
|
|
const rateLimit = await checkRateLimit(
|
|
`managed-profile:create:${user.id}`,
|
|
CREATE_LIMIT,
|
|
CREATE_WINDOW_MS,
|
|
);
|
|
if (!rateLimit.allowed) {
|
|
return apiError("Too many profiles created, try again later", 429);
|
|
}
|
|
const { data: body, error: jsonError } = await safeJson(request, {
|
|
maxBytes: 64 * 1024,
|
|
});
|
|
if (jsonError) return jsonError;
|
|
|
|
const parsed = createManagedProfileSchema.safeParse(body);
|
|
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
|
|
|
const { profile, creatorGrant } = await createManagedProfile({
|
|
creatorId: user.id,
|
|
displayName: parsed.data.displayName,
|
|
dateOfBirth: parsed.data.dateOfBirth
|
|
? new Date(`${parsed.data.dateOfBirth}T00:00:00.000Z`)
|
|
: null,
|
|
locale: parsed.data.locale,
|
|
timezone: parsed.data.timezone,
|
|
// Absent and null both mean "not recorded". The record keeps the same
|
|
// NULL an account that never answered carries, and the cycle module
|
|
// derives its default from it rather than from a guess about the person.
|
|
gender: parsed.data.gender ?? null,
|
|
});
|
|
|
|
await auditLog("managed_profile.created", {
|
|
userId: user.id,
|
|
details: { profileId: profile.id, grantId: creatorGrant.id },
|
|
}).catch(() => {});
|
|
annotate({
|
|
action: { name: "managed_profile.create" },
|
|
meta: { profile_id: profile.id },
|
|
});
|
|
|
|
return apiSuccess(toManagedProfileView(profile), 201);
|
|
});
|