Files
petal/internal/auth/users.go
T
prosolis 071ea7b835 Petal learns French, and the pack that shipped was misspelling itself
Phase 24, the fr half: langpack, Hunspell dictionary, Piper voice, and the
lexicon coverage that turned out to have been measured already (63.1%, better
than pt-PT's 62.1%). No migration; not deployed.

The plan recorded that build_ptpt_dictionary.py "generalizes" to French. It
did not. It handled single-character flags and plain PFX/SFX and stopped on
everything else, and fr.aff uses four of the things it stopped on. FLAG long
is the dangerous one: French flags are two characters, so the old reader's
set(flagstr) yields a bag of unrelated letters and expands every entry through
the wrong paradigm without ever erroring. Plus continuation flags (French
really does affix an affixed form), NEEDAFFIX on 68,075 of 84,140 stems, and
FULLSTRIP. Renamed build_hunspell_dictionary.py with a per-language profile,
asserting that CIRCUMFIX and FORBIDDENWORD are still unused rather than
assuming it — and it rebuilds pt-PT byte-identical to the shipped asset, which
is the only thing that makes "generalized" a claim rather than a hope.

Elision was decided by building both halves and measuring. Keeping l'arbre and
its thirty-three siblings: 3,159,832 forms, 8.25 MB gzipped. Dropping them:
473,326 and 1.19 MB. They are not new words, but the tokenizer keeps internal
apostrophes, so they genuinely would have been underlined — so they moved out
of the dictionary into withElision, which splits at a known clitic and still
requires the remainder to be a word (l'zzzz stays flagged). Real nspell: 369 ms
and 74 MB, against pt-PT's 842 ms and 139 MB, on the larger language.

Where the regional trap lives is the mirror image of Portuguese's: every fr_*
Piper voice is fr_FR and Debian's fr_FR/fr_CA/fr_BE dictionaries are one shared
word list, so nothing can be quietly wrong about the country and the whole
decision sits in the copy. What French has instead is the 1990 reform, packaged
three ways; comprehensive ships, because Petal never corrects her French and
coût and cout are both correct.

Then the interim review pass, at the user's suggestion and explicitly "for
now": four models read each Latin pack independently, and only findings at
least two of them reached on their own were applied — five per pack. It earned
its keep on the pack that was already live. pt-PT was carrying pre-Acordo
spellings (adjectivos, actualmente) in a file whose own header commits to
post-Acordo, plus Brazilian decepção, because the Phase 21 greps checked for
Brazilian vocabulary and never checked the pack against its own spelling
policy. That grep now exists and was confirmed to fail on the old text before
being kept. Where reviewers agreed a line was wrong but split on the fix, the
wording is mine and the reasoning is in BUILD_PLAN rather than averaged away.

Still owed, and both packs now say so precisely: a quorum of models agreeing is
agreement, not authority. No native speaker has read either pack, and none of
this has been seen in a browser.

go build/vet/test clean, tsc, vite build, vitest 190/190.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 16:19:26 -07:00

181 lines
6.1 KiB
Go

package auth
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"strings"
"gitea.parodia.dev/drwily/petal/internal/db"
"gitea.parodia.dev/drwily/petal/internal/httputil"
)
// UserStore provisions and reads accounts. Petal has no signup flow: a row
// appears the first time someone Authentik vouches for signs in, and that is
// the only way one is ever created.
type UserStore struct {
db *sql.DB
}
// NewUserStore returns a store backed by the given database.
func NewUserStore(sqlDB *sql.DB) *UserStore { return &UserStore{db: sqlDB} }
// Upsert records the account behind an OIDC login, keyed by the issuer's
// subject id.
//
// The subject is the id — not the email, which people change and which
// Authentik does not promise is stable. Email and display name are refreshed on
// every login so a rename upstream shows up here; pair_lang is deliberately not
// touched, because it is Petal's own setting rather than the IdP's.
func (u *UserStore) Upsert(sub, email, displayName string) error {
if sub == "" {
return errors.New("oidc: empty subject")
}
if displayName == "" {
displayName = email
}
_, err := u.db.Exec(
`INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
email = excluded.email,
display_name = excluded.display_name`,
sub, email, displayName,
)
return err
}
// Get loads one account.
func (u *UserStore) Get(id string) (db.User, error) {
var user db.User
err := u.db.QueryRow(
`SELECT id, email, COALESCE(display_name, ''), created_at, pair_lang
FROM users WHERE id = ?`, id,
).Scan(&user.ID, &user.Email, &user.DisplayName, &user.CreatedAt, &user.PairLang)
return user, err
}
// MeHandler reports who the caller is. The frontend uses it to namespace
// per-account browser state and to show the signed-in writer; it sits behind
// the auth middleware, so reaching it at all already proves a valid session.
func (u *UserStore) MeHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, err := u.Get(UserID(r.Context()))
if err != nil {
httputil.ErrorJSON(w, http.StatusUnauthorized, "not signed in")
return
}
httputil.WriteJSON(w, http.StatusOK, user)
}
}
// The pairs a writer may actually choose, in the order the picker offers them.
//
// This is deliberately *not* internal/llm's list of languages. That one names
// every pair the prompts know how to talk about, which is a cheap thing to add;
// this one names the pairs Petal can render itself in, which requires a langpack
// on the frontend. Accepting a code with no pack would leave her looking at
// Chinese with no way back except another guess, so the server refuses it. es
// joins this list on the day its pack lands, not before.
var shippedPairs = []string{"zh", "pt-PT", "fr"}
func pairIsShipped(lang string) bool {
for _, p := range shippedPairs {
if p == lang {
return true
}
}
return false
}
// SetPairLang moves an account to another (English + X) pair.
func (u *UserStore) SetPairLang(id, lang string) error {
if !pairIsShipped(lang) {
return errors.New("auth: unshipped pair language " + lang)
}
res, err := u.db.Exec(`UPDATE users SET pair_lang = ? WHERE id = ?`, lang, id)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err == nil && n == 0 {
return sql.ErrNoRows
}
return nil
}
// UpdateMeHandler changes the caller's own settings — today, the one setting
// there is: which language Petal speaks alongside her English.
//
// It answers with the whole updated user rather than an empty 204 so the client
// has one shape to trust: /api/me and this return the same thing, and the app
// re-reads the pair from the response instead of assuming its request took.
//
// The pair language reaches further than the UI copy — it picks her Hunspell
// dictionary, her read-aloud voice, which word-lookup provider answers, and the
// language the prompts ask the model to explain in. All of those read
// `users.pair_lang` at use time, so all of them follow from this one write.
func (u *UserStore) UpdateMeHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
PairLang string `json:"pair_lang"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
httputil.BadRequest(w, "invalid request body")
return
}
lang := strings.TrimSpace(body.PairLang)
if !pairIsShipped(lang) {
// Name the ones that work. A writer who lands here has picked from a
// stale client, and "not a language" tells her nothing.
httputil.BadRequest(w, "unsupported language pair — Petal speaks "+strings.Join(shippedPairs, ", "))
return
}
id := UserID(r.Context())
if err := u.SetPairLang(id, lang); err != nil {
if errors.Is(err, sql.ErrNoRows) {
httputil.ErrorJSON(w, http.StatusUnauthorized, "not signed in")
return
}
httputil.ServerError(w, err)
return
}
user, err := u.Get(id)
if err != nil {
httputil.ServerError(w, err)
return
}
httputil.WriteJSON(w, http.StatusOK, user)
}
}
// Allowlist decides which of Authentik's users may write in this Petal.
// Authentik fronts several applications; being a valid user there does not mean
// being a user here.
//
// An entry matches a subject id or an email address, case-insensitively. Both
// are accepted on purpose: a subject is an opaque uuid nobody can know before
// that person's first login, so a subject-only list means the operator must let
// someone in, read a log line, and edit config — whereas an email is knowable in
// advance. An empty list allows everyone the IdP authenticates, which is the
// right default for a single-household instance.
type Allowlist map[string]bool
// ParseAllowlist builds an Allowlist from a comma-separated env value.
func ParseAllowlist(raw string) Allowlist {
list := Allowlist{}
for _, part := range strings.Split(raw, ",") {
if p := strings.ToLower(strings.TrimSpace(part)); p != "" {
list[p] = true
}
}
return list
}
// Permits reports whether this login may proceed.
func (a Allowlist) Permits(sub, email string) bool {
if len(a) == 0 {
return true
}
return a[strings.ToLower(sub)] || (email != "" && a[strings.ToLower(email)])
}