docs: publish the managed-profile lifecycle routes

The contract already pointed at them: the Guardian roster names
`DELETE /api/managed-profiles/{id}/guardians/{grantId}` as the route its
`grantId` is for, and that route was not in the spec. Creation, deletion,
invitation and removal now are, with the two bodies moved to
`src/lib/validations` so the wire shape has one source.

Three things are stated rather than left to be discovered:

- the two refusals on the invitation that carry no `meta.errorCode`, and the
  order a client must resolve in because of them
- the last-Guardian 409 on the removal route, which nothing can elicit through
  that route today, published with the reason
- that the sentences on the ordinary invitation describe an invited grant only,
  so a client cannot carry its MANAGE rules across to a Guardian grant
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 04:42:51 +02:00
1 parent 26fceca219
commit ec9d93aa24
5 files changed
+640 -28

No files matched your search

@@ -1,5 +1,4 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import {
apiHandler,
@@ -23,6 +22,7 @@ import { ManagedProfileLifecycleError } from "@/lib/managed-profiles/lifecycle";
import { checkRateLimit } from "@/lib/rate-limit";
import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
import { GrantError, inviteManagedProfileGuardian } from "@/lib/sharing/grants";
import { inviteManagedProfileGuardianSchema } from "@/lib/validations/managed-profiles";
type RouteParams = { params: Promise<{ id: string }> };
@@ -77,13 +77,6 @@ export const GET = apiHandler(
const INVITE_LIMIT = 10;
const INVITE_WINDOW_MS = 60 * 60 * 1000;
const guardianInviteSchema = z
.object({
identifier: z.string().trim().min(1).max(255),
expiresAt: z.iso.datetime({ offset: true }).nullable().optional(),
})
.strict();
/** Invite another Guardian from a cookie-backed, freshly verified session. */
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
@@ -100,7 +93,7 @@ export const POST = apiHandler(
maxBytes: 8 * 1024,
});
if (jsonError) return jsonError;
const parsed = guardianInviteSchema.safeParse(body);
const parsed = inviteManagedProfileGuardianSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const { id } = await params;
+2 -16
View File
@@ -1,5 +1,4 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import {
apiHandler,
@@ -16,7 +15,7 @@ import { auditLog } from "@/lib/auth/audit";
import { createManagedProfile } from "@/lib/managed-profiles/create";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { isValidTimezone } from "@/lib/tz/format";
import { createManagedProfileSchema } from "@/lib/validations/managed-profiles";
/**
* The same ceiling its guardian sibling carries, and for the same reason.
@@ -30,19 +29,6 @@ import { isValidTimezone } from "@/lib/tz/format";
const CREATE_LIMIT = 10;
const CREATE_WINDOW_MS = 60 * 60 * 1000;
const managedProfileSchema = z
.object({
displayName: z.string().trim().min(1).max(80),
dateOfBirth: z.iso.date().nullable().optional(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl"]),
timezone: z
.string()
.min(1)
.max(64)
.refine(isValidTimezone, "Invalid IANA timezone"),
})
.strict();
/**
* Create a record a Guardian administers. This deliberately uses the
* cookie-only fresh-MFA gate: a Bearer token cannot mint a credential-less
@@ -65,7 +51,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
const parsed = managedProfileSchema.safeParse(body);
const parsed = createManagedProfileSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const { profile, creatorGrant } = await createManagedProfile({