fix(auth): the refusal code joins the published catalogue

Two structural guards caught the new gate. The error-code catalogue is derived
from the route tree on every run and fails in both directions, so a code the
server emits and does not publish is a client that cannot branch on it; the
session-surface guard freezes the short list of files under `src/app` that
resolve a caller without `requireAuth`, and the register route now belongs on
it — `requireAuth` is the wrong gate there twice over, since it would throw at
the anonymous caller the route exists for and this route wants the ABSENCE of a
session.

The code is `auth.already_authenticated` rather than the bare
`already_authenticated`: the catalogue's own header rules that new codes are
dot.case, and it sits beside `auth.mfa.code_invalid` in the same family. The
OpenAPI document renders the catalogue into the ErrorEnvelope description, so
it is regenerated.
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 09:51:32 +02:00
1 parent 0290554d36
commit d2f47e3bb5
6 files changed
+23 -16

No files matched your search

+13 -13
View File
@@ -29943,19 +29943,19 @@ components:
carry no code at all. ai_provider — ai_provider.invalid, ai_provider.no_fields; allergy —
allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.fact.conflict,
anamnesis.fact.currentExists, anamnesis.fact.invalidValue; auth — auth.admin.required,
auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked,
auth.scope.insufficient, auth.stepup.required, auth.token.expired, auth.token.invalid; backup —
backup.payload.undecryptable, backup.section.missing; coach — coach.conversation.invalidTitle,
coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit,
coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired,
coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs —
coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle —
cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range,
cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled,
cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs —
cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json;
documents — documents.chat.conversationNotFound, documents.chat.invalid,
documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid,
auth.refresh.reuse, auth.refresh.revoked, auth.scope.insufficient, auth.stepup.required,
auth.token.expired, auth.token.invalid; backup — backup.payload.undecryptable, backup.section.missing;
coach — coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict,
coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed,
coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid,
coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape;
consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid,
cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid,
cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit;
cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach —
disable-coach.body.invalid_json; documents — documents.chat.conversationNotFound,
documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound,
documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound,
documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge,
+1 -1
View File
@@ -113,6 +113,6 @@ test.describe("invitation links", () => {
});
expect(res.status()).toBe(409);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("already_authenticated");
expect(body.meta?.errorCode).toBe("auth.already_authenticated");
});
});
@@ -79,6 +79,12 @@ describe("S1 — the direct session-resolution set is frozen", () => {
// the freshness comparison, which `requireAuth` does not project.
"app/api/auth/native/complete/route.ts":
"reads createdAt for the handoff freshness check",
// Anonymous self-registration. Probes for an already-valid session so an
// invitation opened from inside one is refused instead of minting a second
// account over the caller's own cookie. `requireAuth` is the wrong gate
// here twice over: it would throw 401 at the anonymous caller the route
// exists for, and this route wants the ABSENCE of a session.
"app/api/auth/register/route.ts": "refuses to register over a live session",
// Anonymous IdP callback. Probes for an already-valid session so a
// replayed callback redirects instead of minting a second identity.
"app/api/auth/oidc/callback/route.ts":
@@ -340,7 +340,7 @@ describe("POST /api/auth/register — refuses a live session (v1.38.19)", () =>
meta?: { errorCode?: string };
};
expect(body.data).toBeNull();
expect(body.meta?.errorCode).toBe("already_authenticated");
expect(body.meta?.errorCode).toBe("auth.already_authenticated");
expect(body.error).toMatch(/sign/i);
});
+1 -1
View File
@@ -60,7 +60,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError(
"Already signed in — sign out first to use an invitation",
409,
{ errorCode: "already_authenticated" },
{ errorCode: "auth.already_authenticated" },
);
}
+1
View File
@@ -46,6 +46,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
],
auth: [
"auth.admin.required",
"auth.already_authenticated",
"auth.mfa.code_invalid",
"auth.missing",
"auth.refresh.invalid",