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
181 lines
6.1 KiB
Go
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)])
|
|
}
|