Phase 25. Spanish was never built — the groundwork was all [x] (DreamDict data, the prompt language, the L1 rule gating, TTS env-discovery), which is why the plan read as though it had shipped. shippedPairs was the honest answer: the server had been refusing es on purpose. The langpack is neutral Latin American, chosen with the user: tú, ustedes, no vosotros, and the pan-American half of every vocabulary split. A vitest greps for the peninsular twins the way fr is greped for québécismes — including coger, which is not merely regional but obscene through most of Latin America. The dictionary is the story. Debian's hunspell-es symlinks twenty country codes to one file, which reads as pan-Hispanic; RLA publishes twenty-four builds per release, one per country plus a generic es that is the union, and Debian ships peninsular es_ES. The 58,622-form gap is essentially voseo, so the first version of this commit underlined vení and tenés as misspellings and called it a considered gap. The MUST_ACCEPT list was written to catch exactly that and structurally could not: it asserted the pan-Hispanic vocabulary, and every RLA variant carries the full pan-Hispanic vocabulary — only the paradigms are localised. The REP table cited as the second witness is shared by all builds too. Two independent-looking proofs, neither able to distinguish anything, agreeing with each other. The profile now demands what discriminates, each verified against the build it targets: voseo rejects es_ES and Debian, vosotros rejects es_MX, and arepa/chévere/bacán reject es_AR, which has both paradigms and would otherwise pass. 717,640 forms, 1.74 MB gzipped, 762 ms / 97 MB in a real nspell. fr and pt-PT rebuild byte-identical from their own upstream debs, so the shared script still means what it meant. Shipping the union is fr's call arrived at from the other side: coût and cout are both correct French, tienes and tenés are both correct Spanish. The dictionary holds every variety because underlining is all it can do; the copy picks a register because speaking requires one. Reviewed by four models at the usual >=2-of-4 threshold, 5 of 27 findings applied — one catching the bedtime proverb as fr's Qui dort dîne calqued into Spanish, gloss and all, which is the rule the fr header states. One below-threshold finding (a missing ¡, seen by 1 of 4 because an absent opening mark has no closing ! to look wrong against) was applied and turned into an assertion instead: the suite now rejects any native line that closes ? or ! without opening one. piper-es on es_MX-ald-medium, not the es_ES-davefx-medium the plan named — six of Piper's nine Spanish voices are peninsular, so the obvious pick was the pt-PT trap through a different door. go build/vet/test, tsc, vite, vitest 251/251. Not deployed, not seen in a browser, not read by a native speaker, and no es account exists.
186 lines
6.4 KiB
Go
186 lines
6.4 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
|
|
// joined on the day its pack landed, not before.
|
|
//
|
|
// These four are now every pair PairLang names on the frontend, which makes the
|
|
// two lists look redundant. They are not: the next pair will exist in the type
|
|
// and in the prompts long before it has copy, and this list is the one that
|
|
// says a writer may actually be sent there.
|
|
var shippedPairs = []string{"zh", "pt-PT", "fr", "es"}
|
|
|
|
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)])
|
|
}
|