Replace paliad's private GoTrue Admin client with the m/mAuth module — same behaviour, one implementation #164
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
paliad invented this pattern and it works in production.
m/mAuthis that pattern extracted into a shared module, and three other services now adopt it: youpc (built, on dev), hihlc (filed), and any service after them.That leaves paliad as the one product running a private copy of the thing it originated. m's goal, stated 2026-07-31: "unify the whole handling for different services. They should be the same … but they should allow custom branding, which we do via paliad already." paliad already has the branding half right. This issue closes the other half.
This is not a rewrite and not a behaviour change. paliad's recovery mail works, its templates and languages stay exactly as they are, and the user-visible flow must be identical afterwards. What changes is which code mints and redeems.
Why it is worth doing rather than leaving alone
The copy has already drifted. mAuth fixed eight defects that paliad's originals still carry. Two verified in your tree on 2026-07-31:
1.
strings.Contains(lower(body), "already")classifies by body text —internal/services/supabase_admin.go:121and:174. Corrected bypaliad/headafter filing: these two sites areCreateAuthUserand the admin email update.GenerateRecoveryTokenHashat:209does not share the classifier, so paliad's recovery path is not affected. My original text said it was; it was wrong.What remains open is whether the branch is reachable at all. Both sites guard on
422 || 409before testing the substring, and GoTrue rate-limits with 429 — so the rate-limit wording never arrives, and on a 422 or 409 fromCreateAuthUser"already" almost certainly is email-exists. Do not spend the shift on this. Confirm reachability, note the answer in the comment, move on.For the record on the mAuth side, since it is the reason this was filed: mAuth's classifier narrows on
status >= 400 && status < 500and nothing tighter (go/mint.go:197), deliberately — GoTrue's choice between 400, 404 and 422 for one condition is not stable across versions, so the body is the discriminator. That looseness is what let a 429 reach the substring test, and it was reproduced red on a recovery before the fix. paliad's tighter guard removes that collision and takes the mirror-image risk instead: a GoTrue version answering email-exists with a 400 slips past422 || 409. Worth a sentence in your comment, not a fix.2. The recovery URL is built by concatenation —
internal/services/password_reset_service.go:164doesRecoveryRedirectFor(baseURL) + "?token_hash=" + …. It is safe today only becauseRecoveryRedirectForreturns a path with no query string. Add one parameter — a language, anext— and the result is…?lang=de?token_hash=…, one parameter whose value swallows the token. mAuth's builder parses and sets (m/mAuth#3, finding 5).The others mAuth fixed and paliad still has: only two of the four token-hash sources are tried; a 5xx redemption reported as a dead token;
Deliverywithout the verification type; a JSON parse error quoting the body that carries the token;SetPassword("")reported as a token judgement.None of these has bitten paliad, because paliad only does
recoverywith a single-language-per-user template and a path with no query string. Every one of them bites the moment paliad does something slightly different. Fixing them twice — once here, once in mAuth — is the cost this issue removes.Scope
mgit.msbls.de/m/mAuth/goas a dependency. Zero dependencies of its own,go test -racegreen at 94.2 %. Do not vendor it.internal/services/supabase_admin.goandinternal/services/password_reset_service.gowith the module'sClientandLinkService.auth_identity_for_recovery/db.Querier→IdentityResolverMailService+TemplateData+ the de/en enum →LinkDeliverer, with the language carried inIdentity.Attrsemaildomain.Canonicalize(hoganlovells.com↔hlc.com) → theCanonicalizehookRecoveryLinkPath→RedirectTo, and drop the hand-built URL for the module's builder71f663d.docs/adoption.mdinm/mAuthis the guide.examples/go/is the worked Go consumer.The one thing to check carefully — you are the alias case
mAuth has an open divergence,
m/mAuth#7: Go passes the resolver the address as typed, TypeScript passes the canonical one. It is invisible with alower(trim())canonicaliser and bites the first consumer with domain aliases.That is paliad.
emaildomain.Canonicalizefoldshoganlovells.comandhlc.com, so typed and canonical are genuinely different strings here.Before you wire the resolver, establish which form paliad's
auth_identity_for_recoveryactually expects, and say so in your comment. That answer decidesm/mAuth#7, which is currently unassigned and waiting for exactly this evidence. If the module hands your resolver the wrong form, stop and report it — do not compensate inside your resolver, because that hides the divergence from every consumer after you.Explicitly NOT in this issue
CreateAuthUser,UpdateAuthUserEmailorDeleteAuthUser. Those stayed in paliad deliberately — they are not mint/compose/redeem, and carrying them makes mAuth a general Supabase admin SDK. They stay yours.API_EXTERNAL_URL(m/otto#511). Do not addissvalidation (contract §7.4).m/mAuth.ritchieandknuth— both are active in this tree.Definition of done
m/mAuth/go; the private Admin client for those two paths is gone.m/mAuth#7.docs/adoption.mdthat was wrong for the consumer that wrote the original.Refs:
m/mAuthdocs/adoption.md,examples/go/, #1 #2 #3 #4 #7;paliad71f663d(scanner-safe POST redemption); yoUPC/youpc.org#214; m/hihlc#23.Adopted on
mai/knuth/adopt-mauth-module— 1e2ef89. Full gate green, no migration.mAuth#7 — measured, not reasoned
paliad.auth_identity_for_recoverytakes BOTH forms, and neither alone is sufficient.Signature, read from prod:
How I established it. Not by reading that body — by seeding rows in a real database and calling the function.
internal/services/recovery_identity_form_live_test.go, which stays as a regression test:(alias, alias)— typed form only(alias, canonicalize(alias))(stored, canonicalize(stored))The first row is the answer. A resolver handed only the typed address finds nobody for a colleague typing their old address. So the fold goes in paliad's resolver, exactly as
docs/adoption.md§3.1 now describes.Why the pair is not redundant. A second test seeds one row per domain, alias row first, so
created_atalone would always pick the alias._typedis what makes the function return the address the person actually asked for. Handed only the canonical form a resolver cannot express that preference at all — it silently degrades to created_at order. That is the property a one-argument interface cannot carry, and it is the concrete reason paliad needed typed rather than merely preferring it.A dependency that was not obvious. The function compares against
lower(a.email), so an upper-case_typednever matches on the typed arm. mAuth lower-cases and trims before the resolver sees the address (go/service.go,LinkService.Request), andemaildomain.Canonicalizelower-cases too. Belt and braces, and the subtest fails if either stops being true.Caveat I will not overstate. This changes no answer in prod today. The hlc.com rename has run — zero
hoganlovells.comrows remain, 60 firm rows all canonical — so no stored row differs from its canonical form. It was load-bearing four days ago at 53 alias rows, and becomes load-bearing again for any future alias. The decision was correctly made on principle rather than on this data.Defect 2 — confirmed and fixed
RecoveryLinkForconcatenated"?token_hash=". Safe only while that path carries no query string; add one parameter and you get?lang=de?token_hash=…, a single parameter whose value swallows the token. The user sees "invalid or already used" — indistinguishable from the scanner bug this path exists to fix. Now parses and sets; the request flow uses mAuth's defaultBuildURL. Tested against four base URLs, two of which already carry a query string.Defect 1 — unreachable in paliad, recorded rather than fixed
paliad's two
"already"sites guard on 422 or 409 before the substring test. GoTrue answers a rate limit with 429, so its wording never arrives. mAuth narrows on 4xx and nothing tighter, which is why the same string does reach theirs. Pinned byTestSupabaseAdminClient_RateLimitIsNotReadAsEmailExistswith a 429 fixture carrying the real wording.The mirror-image risk, recorded because neither trade is obviously right. A tight status guard cannot be fooled by a body, but misses email-exists if some GoTrue version answers it with a 400. mAuth took the opposite trade — loose status, discriminating body — and paid for it with the 429 collision. Written down so the next reader finds a decision rather than an accident.
What came out
internal/auth/recovery.goandSupabaseAdminClient.GenerateRecoveryTokenHashare deleted: after the rewiring they had no production caller and were reachable only from their own tests.CreateAuthUser/UpdateAuthUserEmail/DeleteAuthUserstay — user administration, not auth-mail.The admin add-user welcome now mints through the same client rather than a second implementation, so the welcome mail and the reset mail cannot drift into different URL shapes. It deliberately does not go through
LinkService: that flow caps per identity and goes silent on an unknown address, and an admin creating a user needs the failure reported.One consequence for whoever deploys this
mAuth requires Go 1.25, so
go.modmoves1.24.0→1.25.0for the whole project. Pinned as a full version deliberately:go getwrites the two-component1.25, andscripts/check-gofmt.shresolves the toolchain asgo$(directive)—go1.25is not a fetchable toolchain name, so the gate exits 2, the check-did-not-run case. CI reads the same file viago-version-file.