fix: answer the managed-profile Guardian routes in one shape

- publish the roster's entry from the invitation and `{ grantId, revokedAt }`
  from the removal, instead of the account-grant row Prisma returned
- drop the invitation's self refusal: it named a code no request can elicit,
  because a Guardian grant's grantor is the profile and never the caller
- name the duplicate grant error rather than catching every grant refusal into
  a sentence that would be wrong for any other one
- keep the rate limit and the unknown-identifier 404 free of an error code,
  as their siblings elsewhere are, and say why at the call site
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 04:39:50 +02:00
1 parent eb9b03a788
commit 26fceca219
5 files changed
+195 -24

No files matched your search

@@ -31,7 +31,21 @@ export const DELETE = apiHandler(
action: { name: "managed_profile.guardian.revoked" },
meta: { profile_id: id, grant_id: grant.id },
});
return apiSuccess(grant);
// Two fields, not the grant row this used to hand back whole. What ended
// and when is the entire answer; the party who held it is already on the
// roster the caller is about to re-read, and `grantorId`, `granteeId` and
// `scopeJson` are a table's columns rather than a contract.
const { revokedAt } = grant;
if (!revokedAt) {
// The service stamps it inside the transaction it returns from, so
// this cannot happen — and if it ever does, an invented timestamp
// would be worse than the operator hearing about it.
throw new Error("A revoked Guardian grant carries no revocation time");
}
return apiSuccess({
grantId: grant.id,
revokedAt: revokedAt.toISOString(),
});
} catch (error) {
if (error instanceof LastManagedGuardianError) {
return apiError("Add another Guardian before ending this access", 409, {
@@ -15,9 +15,13 @@ import {
} from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { listManagedProfileGuardians } from "@/lib/managed-profiles/guardian-list";
import {
listManagedProfileGuardians,
toManagedProfileGuardianView,
} from "@/lib/managed-profiles/guardian-list";
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";
type RouteParams = { params: Promise<{ id: string }> };
@@ -107,8 +111,17 @@ export const POST = apiHandler(
{ email: { equals: parsed.data.identifier, mode: "insensitive" } },
],
},
select: { id: true },
select: GRANT_PARTY_SELECT,
});
// No `errorCode`, deliberately, and the same for the 429 above. Rate-limit
// refusals carry none anywhere in this application, and the sibling
// invitation route (`POST /api/account/grants`) answers an unknown
// identifier with a bare 404 too. A code here would make one route in the
// family unlike both. The published contract says so, and says that a
// client resolves on `meta.errorCode` FIRST: this route's other 404 —
// "you are not a Guardian of that profile" — does carry one, and branching
// on the status would tell somebody to check a spelling when the real
// answer is that the profile is no longer theirs to administer.
if (!invitee) return apiError("No account with that name or e-mail", 404);
try {
@@ -124,18 +137,31 @@ export const POST = apiHandler(
action: { name: "managed_profile.guardian.invited" },
meta: { profile_id: id, grant_id: grant.id },
});
return apiSuccess(grant, 201);
// The roster's entry, not the grant row. A fresh invitation is always
// PENDING, so the mapper cannot answer null here — but it decides that
// rather than this route asserting it.
const view = toManagedProfileGuardianView(grant, invitee);
if (!view) throw new Error("A new invitation resolved to an ended grant");
return apiSuccess(view, 201);
} catch (error) {
if (error instanceof GrantError && error.code === "self_grant") {
return apiError("An account cannot be shared with itself", 422, {
errorCode: "managed_profile.guardian.self",
});
}
if (error instanceof GrantError) {
if (
error instanceof GrantError &&
error.code === "duplicate_live_grant"
) {
return apiError("That account already has access", 409, {
errorCode: "managed_profile.guardian.duplicate",
});
}
// No arm for `self_grant`, and its absence is a decision. It fires when
// the grantor equals the grantee; in this family the grantor is the
// PROFILE, never the caller, so inviting yourself names somebody who is
// not the grantor and lands on the duplicate refusal above, while naming
// the profile itself is refused one step earlier as `managed_grantee`.
// The 422 that used to sit here published `managed_profile.guardian.self`
// — a code no request could elicit, on a route this release freezes. A
// grant refusal nothing can produce is not caught into a sentence that
// would then be wrong; it reaches the operator as a 500 with its wide
// event, which is what an impossible state deserves.
if (error instanceof ManagedProfileLifecycleError) {
if (error.code === "managed_grantee") {
return apiError("A managed profile cannot be a Guardian", 422, {