A person leaves licences out, and no key of theirs reaches a model under one
CI / test (push) Successful in 11m28s

A game sold in the EU cannot ship what a licence excluding the EU made. On
the profile page a person leaves out licences one by one, or by rule:
every licence forbidding commercial use, every licence granting nothing in
a region. The rules cover models added later. The choice reaches every
principal through the resolved limits, so permits() leaves such models out
of /v1/models, requests, jobs, fallbacks and the portal's pickers, and a
request for one is refused 403 licence_excluded saying which choice did.

Kept in user_excluded_licences (migration 0046), copied with the store and
gone with the person.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-11 16:10:22 +02:00
1 parent a313b3e0da
commit 4bbac0833d
20 files changed
+658 -7

No files matched your search

+5
View File
@@ -60,6 +60,11 @@ APIs change between minors without a deprecation period.
up now; a picture is cut out and put on grey before TripoSR sees it
(`LLMGWPrepareForMesh`, rembg when it has no alpha).
- **Licences a person leaves out**: on the profile page, by licence, or by
rule — every licence forbidding commercial use, every licence granting
nothing in a region — so a model under one is neither listed to nor
served for that person's keys and sessions (403 `licence_excluded`).
### Fixed
- A model waiting for another on-demand model to give up the card failed at
+14 -1
View File
@@ -534,6 +534,19 @@ curl https://llm.example.com/mcp \
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
```
### Licences you leave out
A model's licence is in `/v1/models` (`license`: its `id`, `name`, whether
it allows commercial use and the regions it grants nothing in). On your
profile page, under **Licences**, you can leave licences out: one by one,
every licence that forbids commercial use, or every licence that grants
nothing in a region you name (`EU` for the union). The rules cover models
added later too. From then on those models are not listed to your keys and
a request for one is refused with 403 and the code `licence_excluded`,
naming the choice that left it out — on every key you own and on the
portal's pages, whatever a key's own allowlist says. A change reaches keys
already in use within a minute.
### Headers llmgw sends back
| Header | Says |
@@ -553,7 +566,7 @@ its usual exception with the gateway's message in it. The ones worth knowing:
|---|---|
| 400 | Besides a malformed request: a prompt too long for the model, or a guardrail policy refused it (the code `content_policy_violation`). |
| 401 | No key, a mistyped one, or one that was revoked or has expired. |
| 403 | The key may not use that model. |
| 403 | The key may not use that model; with the code `licence_excluded`, its licence is one you left out on your profile page. |
| 429 | A rate limit or budget of the key, its project or its team was reached. `Retry-After` says when to try again, and is absent when waiting will not help — a spent monthly budget. |
| 503 | No model of that name is served (the message lists the ones that are), the model is still loading, or every replica is busy and the queue is full. The SDKs retry by themselves; a name that is not served will not start to be. |
| 504 | Every replica was busy for as long as the key's band may wait. `Retry-After` says when to send it again. |
+3
View File
@@ -58,6 +58,9 @@ func (r *Router) registerChat(mux *http.ServeMux, pages *web.AccountPages) {
}))
r.registerImagesPage(mux, pages)
r.registerMeshesPage(mux, pages)
if r.store != nil {
pages.SetLicences(r.licenceChoices, r.store)
}
}
// chatSend dispatches one turn on behalf of a signed-in person.
+6 -1
View File
@@ -546,7 +546,7 @@ func (r *Router) handleInference(path string) http.HandlerFunc {
}
if p, ok := auth.FromContext(req.Context()); ok {
if !r.permits(p, parsed.Model) {
auth.WriteForbiddenModel(w, parsed.Model)
r.writeForbidden(w, p, parsed.Model)
r.m.observeRequest(r.metricModel(parsed.Model), "", http.StatusForbidden, time.Since(start), nil, 0)
return
}
@@ -702,6 +702,11 @@ func (r *Router) permits(p auth.Principal, requested string) bool {
return true
}
canonical, m := r.resolve(requested)
// A licence the person has said not to use leaves the model out
// whatever the allowlist says: it is theirs to narrow, not to widen.
if licenceExcluded(p.Limits.ExcludedLicences, m) != "" {
return false
}
if p.Allows(canonical) {
return true
}
+1 -2
View File
@@ -11,7 +11,6 @@ import (
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
@@ -107,7 +106,7 @@ func (r *Router) createJob(w http.ResponseWriter, req *http.Request, path string
return
}
if !r.permits(p, in.Model) {
auth.WriteForbiddenModel(w, in.Model)
r.writeForbidden(w, p, in.Model)
return
}
if why := r.imageRouteRefusal(path, in.Model, body); why != "" {
+128
View File
@@ -0,0 +1,128 @@
package router
import (
"fmt"
"net/http"
"slices"
"sort"
"strings"
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/web"
)
// What a person may say not to use, besides a licence by its id: every
// licence that forbids commercial use, and every licence that grants nothing
// in a region. Rules rather than a list, so a model added later under such
// a licence is left out without anybody remembering to tick it.
const (
licenceUnspecified = "unspecified"
ruleNonCommercial = "rule:non-commercial"
ruleRegionPrefix = "rule:region:"
)
// licenceKey is how a model's licence is matched: its id, else its name,
// lowercased; licenceUnspecified for a model whose entry names none.
func licenceKey(m catalog.Model) string {
if m.License == nil {
return licenceUnspecified
}
if m.License.ID != "" {
return strings.ToLower(m.License.ID)
}
return strings.ToLower(strings.TrimSpace(m.License.Name))
}
// licenceExcluded says which of the person's choices leaves a model out,
// empty when none does.
func licenceExcluded(excluded []string, m catalog.Model) string {
if len(excluded) == 0 {
return ""
}
key := licenceKey(m)
for _, e := range excluded {
switch {
case strings.EqualFold(e, key):
return e
case e == ruleNonCommercial:
if m.License != nil && m.License.Commercial != nil && !*m.License.Commercial {
return e
}
case strings.HasPrefix(e, ruleRegionPrefix):
if m.License != nil && slices.Contains(m.License.ExcludedRegions, strings.TrimPrefix(e, ruleRegionPrefix)) {
return e
}
}
}
return ""
}
// excludedByLicence says why the principal's person has left this model
// out, empty when they have not.
func (r *Router) excludedByLicence(p auth.Principal, requested string) string {
if len(p.Limits.ExcludedLicences) == 0 {
return ""
}
_, m := r.resolve(requested)
return licenceExcluded(p.Limits.ExcludedLicences, m)
}
// licenceChoices are what the profile page offers: each licence the
// catalog's models are under, with the models, and the regions any of them
// excludes.
func (r *Router) licenceChoices() web.LicenceChoices {
by := map[string]*web.LicenceChoice{}
regions := map[string]bool{}
nonCommercial := false
for name, m := range r.modelMap() {
if name != m.Name || r.hiddenModel(name) {
continue // an alias, or a guardrail's judge nobody asks for
}
key := licenceKey(m)
c, ok := by[key]
if !ok {
c = &web.LicenceChoice{Key: key}
if m.License != nil {
c.Name, c.URL = m.License.Name, m.License.URL
if cm := m.License.Commercial; cm != nil {
c.Commercial = map[bool]string{true: "yes", false: "no"}[*cm]
}
c.ExcludedRegions = m.License.ExcludedRegions
}
by[key] = c
}
c.Models = append(c.Models, m.Name)
if m.License != nil {
for _, reg := range m.License.ExcludedRegions {
regions[reg] = true
}
if m.License.Commercial != nil && !*m.License.Commercial {
nonCommercial = true
}
}
}
var out web.LicenceChoices
for _, c := range by {
sort.Strings(c.Models)
out.Licences = append(out.Licences, *c)
}
sort.Slice(out.Licences, func(i, j int) bool { return out.Licences[i].Key < out.Licences[j].Key })
for reg := range regions {
out.Regions = append(out.Regions, reg)
}
sort.Strings(out.Regions)
out.AnyNonCommercial = nonCommercial
return out
}
// writeForbidden refuses a model the principal may not use, saying why when
// it is the person's own choice of licences rather than the key's allowlist.
func (r *Router) writeForbidden(w http.ResponseWriter, p auth.Principal, model string) {
if why := r.excludedByLicence(p, model); why != "" {
writeErrorCode(w, http.StatusForbidden, "invalid_request_error", "licence_excluded",
fmt.Sprintf("the model %q is under a licence its account has chosen not to use (%s); change it on the profile page", model, why))
return
}
auth.WriteForbiddenModel(w, model)
}
+86
View File
@@ -0,0 +1,86 @@
package router
import (
"crypto/sha256"
"net/http"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func TestAPersonsLicenceChoiceLeavesModelsOutOfTheirKeys(t *testing.T) {
// A game sold in the EU cannot ship what a licence excluding the EU
// made: its maker says so once, and no key of theirs reaches such a model.
yes, no := true, false
names := []string{"mit", "nc", "eu-out", "plain"}
e := newFakeEngine(t, "m")
cfg := baseConfig(config.Endpoint{ID: "a", URL: e.srv.URL, Models: names})
mk := func(name string, l *catalog.License) catalog.Model {
m := catModel(name, nil)
m.License = l
return m
}
cfg.Models = []catalog.Model{
mk("mit", &catalog.License{ID: "MIT", Name: "MIT", Commercial: &yes}),
mk("nc", &catalog.License{ID: "CC-BY-NC-4.0", Name: "CC BY-NC 4.0", Commercial: &no}),
mk("eu-out", &catalog.License{ID: "tencent-hunyuan-community", Name: "Tencent Hunyuan Community",
ExcludedRegions: []string{"EU", "UK", "KR"}}),
mk("plain", nil),
}
cfg.Auth.Enabled = true
cfg.Auth.Store = filepath.Join(t.TempDir(), "store.db")
rt := newTestRouter(t, cfg)
ctx := t.Context()
if err := rt.store.CreateUser(ctx, store.User{ID: "usr-g", Email: "g@game.test", State: store.UserActive,
CreatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
secret := store.KeyPrefix + strings.Repeat("g", 43)
sum := sha256.Sum256([]byte(secret))
if err := rt.store.CreateUserKey(ctx, "usr-g", store.Key{ID: "key-g", Hash: sum[:], Prefix: "gggg",
Name: "game", CreatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
if err := rt.store.SetUserExcludedLicences(ctx, "usr-g",
[]string{"rule:non-commercial", "rule:region:EU", "unspecified"}); err != nil {
t.Fatal(err)
}
h := rt.Handler()
_, list, raw := call(t, h, secret, http.MethodGet, "/v1/models", nil)
var listed []string
for _, m := range list["data"].([]any) {
listed = append(listed, m.(map[string]any)["id"].(string))
}
if !slices.Equal(listed, []string{"mit"}) {
t.Errorf("listed %v: %s", listed, raw)
}
for _, name := range []string{"nc", "eu-out", "plain"} {
code, _, raw := call(t, h, secret, http.MethodPost, "/v1/chat/completions",
map[string]any{"model": name, "messages": []any{map[string]any{"role": "user", "content": "hi"}}})
if code != http.StatusForbidden || !strings.Contains(raw, "licence_excluded") {
t.Errorf("%s: %d %s", name, code, raw)
}
}
if code, _, raw := call(t, h, secret, http.MethodPost, "/v1/chat/completions",
map[string]any{"model": "mit", "messages": []any{map[string]any{"role": "user", "content": "hi"}}}); code != 200 {
t.Errorf("mit: %d %s", code, raw)
}
// The profile page is offered every licence, and the regions in play.
c := rt.licenceChoices()
var keys []string
for _, l := range c.Licences {
keys = append(keys, l.Key)
}
if !slices.Equal(keys, []string{"cc-by-nc-4.0", "mit", "tencent-hunyuan-community", "unspecified"}) ||
!slices.Equal(c.Regions, []string{"EU", "KR", "UK"}) || !c.AnyNonCommercial {
t.Errorf("choices %+v", c)
}
}
+2
View File
@@ -25,6 +25,8 @@ var copyOrder = []string{
"users",
// After users and the three levels of the tree, which it points at.
"memberships",
// After users, which it points at.
"user_excluded_licences",
"api_keys",
"key_claims",
"sessions",
+54
View File
@@ -0,0 +1,54 @@
package store
import (
"context"
"slices"
"strings"
)
// UserExcludedLicences is what a person has said not to use, sorted: a
// licence by its id, or a rule over licences ("rule:non-commercial",
// "rule:region:EU"). What each means is the router's to match; the store
// keeps the words.
func (s *DB) UserExcludedLicences(ctx context.Context, userID string) ([]string, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT licence FROM user_excluded_licences WHERE user_id = ? ORDER BY licence`, userID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var out []string
for rows.Next() {
var l string
if err := rows.Scan(&l); err != nil {
return nil, err
}
out = append(out, l)
}
return out, rows.Err()
}
// SetUserExcludedLicences replaces what a person has said not to use; an
// empty list excludes nothing.
func (s *DB) SetUserExcludedLicences(ctx context.Context, userID string, licences []string) error {
keys := make([]string, 0, len(licences))
for _, l := range licences {
if l = strings.TrimSpace(l); l != "" {
keys = append(keys, l)
}
}
slices.Sort(keys)
keys = slices.Compact(keys)
return s.inTx(ctx, func(c conn) error {
if _, err := c.ExecContext(ctx, `DELETE FROM user_excluded_licences WHERE user_id = ?`, userID); err != nil {
return err
}
for _, k := range keys {
if _, err := c.ExecContext(ctx,
`INSERT INTO user_excluded_licences (user_id, licence) VALUES (?, ?)`, userID, k); err != nil {
return err
}
}
return nil
})
}
+36
View File
@@ -0,0 +1,36 @@
package store
import (
"slices"
"testing"
"time"
)
func TestAPersonsExcludedLicencesReachEveryWayTheyAsk(t *testing.T) {
db := openDB(t)
ctx := t.Context()
if err := db.CreateUser(ctx, User{ID: "usr-1", Email: "a@b.c", State: UserActive, CreatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
if err := db.SetUserExcludedLicences(ctx, "usr-1", []string{"tencent-hunyuan-community", " CC-BY-NC-4.0", "", "CC-BY-NC-4.0", "rule:region:EU"}); err != nil {
t.Fatal(err)
}
got, err := db.UserExcludedLicences(ctx, "usr-1")
want := []string{"CC-BY-NC-4.0", "rule:region:EU", "tencent-hunyuan-community"}
if err != nil || !slices.Equal(got, want) {
t.Errorf("excluded %v %v, want %v", got, err, want)
}
// A session, which has no key row, gets them as a key does.
eff, err := db.ResolveFor(ctx, "session:usr-1", "usr-1", "")
if err != nil || !slices.Equal(eff.ExcludedLicences, want) {
t.Errorf("session: %v %v", eff.ExcludedLicences, err)
}
if err := db.SetUserExcludedLicences(ctx, "usr-1", nil); err != nil {
t.Fatal(err)
}
if got, _ := db.UserExcludedLicences(ctx, "usr-1"); len(got) != 0 {
t.Errorf("cleared, still %v", got)
}
}
+7
View File
@@ -224,6 +224,13 @@ func (s *DB) ResolveFor(ctx context.Context, callerID, userID, projectID string)
// person's limit by every key they own; a per-member limit by one person
// within one scope.
func (s *DB) resolveChain(ctx context.Context, e *Effective, keyLevel bool) error {
if e.UserID != "" {
ex, err := s.UserExcludedLicences(ctx, e.UserID)
if err != nil {
return err
}
e.ExcludedLicences = ex
}
if e.ProjectID != "" {
err := s.db.QueryRowContext(ctx,
`SELECT t.id, t.org_id FROM projects p JOIN teams t ON t.id = p.team_id WHERE p.id = ?`,
@@ -0,0 +1,9 @@
-- Ported from the SQLite migration of the same number.
-- The licences a person has said not to use. Models under them are not
-- listed to the person, nor served to their keys or their sessions: a
-- game sold in the EU cannot ship what a licence excluding the EU made.
CREATE TABLE user_excluded_licences (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
licence TEXT NOT NULL,
PRIMARY KEY (user_id, licence)
);
@@ -0,0 +1,8 @@
-- The licences a person has said not to use. Models under them are not
-- listed to the person, nor served to their keys or their sessions: a
-- game sold in the EU cannot ship what a licence excluding the EU made.
CREATE TABLE user_excluded_licences (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
licence TEXT NOT NULL,
PRIMARY KEY (user_id, licence)
);
+3
View File
@@ -165,6 +165,9 @@ type Effective struct {
BudgetPeriod Period
// AllowedModels is the intersection along the chain. nil means every model.
AllowedModels []string
// ExcludedLicences are the licences the caller's person has said not to
// use, as LicenceKey gives them; none for a key without a person.
ExcludedLicences []string
// RPMOwner, TPMOwner and BudgetOwner say which scope set the value that
// won, so consumption can be counted against that scope rather than
+14 -3
View File
@@ -95,9 +95,13 @@ type AccountPages struct {
hasSettings bool
// images says whether some model makes pictures; nil when the
// pictures page is not mounted.
images func() bool
meshes func() bool
guardLibrary func() guard.Config
// licenceChoices and licencePrefs offer the choice of licences on the
// profile page; nil offers none.
licenceChoices func() LicenceChoices
licencePrefs LicencePrefs
images func() bool
meshes func() bool
guardLibrary func() guard.Config
// guardFor names the guardrail policies in force for someone, and
// whether they keep nothing; nil when there are none.
guardFor func(guard.Subject) (string, bool)
@@ -193,6 +197,7 @@ func (p *AccountPages) Register(mux *http.ServeMux) {
mux.HandleFunc("POST /profile/keys", p.selfAudited(p.createKey))
mux.HandleFunc("POST /profile/keys/{id}/revoke", p.selfAudited(p.revokeKey))
mux.HandleFunc("POST /profile/password", p.selfAudited(p.changePassword))
mux.HandleFunc("POST /profile/licences", p.selfAudited(p.setLicences))
mux.HandleFunc("POST /profile/connect", p.selfAudited(p.connect))
mux.HandleFunc("GET /setup/{tool}", p.setupScript)
}
@@ -379,6 +384,11 @@ type profileData struct {
Models []string
Error string
// Licences is the choice of licences not to use; nil when not offered.
Licences *LicenceChoices
LicencesSaved bool
LicenceError string
PasswordChanged bool
PasswordError string
MinPassword int
@@ -505,6 +515,7 @@ func (p *AccountPages) render(w http.ResponseWriter, r *http.Request,
}
d.Keys = keys
p.fillPlaces(r.Context(), langOf(r), user, &d)
p.fillLicences(r.Context(), user.ID, &d)
render(w, r, "profile", d)
}
+142
View File
@@ -0,0 +1,142 @@
package web
import (
"context"
"net/http"
"regexp"
"slices"
"strings"
)
// LicenceChoice is one licence the catalog's models are under, as the
// profile page offers it.
type LicenceChoice struct {
// Key is what is stored when it is ticked: the licence's id, else its
// name, lowercased; "unspecified" for models whose entry names none.
Key string
Name, URL string
// Commercial is "yes" or "no" as the licence says, empty when it does
// not say.
Commercial string
ExcludedRegions []string
Models []string
// Excluded is whether the person has left it out.
Excluded bool
}
// LicenceChoices is the licences on offer, and what the rules can be about.
type LicenceChoices struct {
Licences []LicenceChoice
// Regions are those some licence in the catalog grants nothing in.
Regions []string
// AnyNonCommercial is whether some licence forbids commercial use.
AnyNonCommercial bool
// What the person has chosen, for the rules.
NonCommercial bool
ExcludedRegions []string
}
// LicencePrefs keeps what a person has said not to use.
type LicencePrefs interface {
UserExcludedLicences(ctx context.Context, userID string) ([]string, error)
SetUserExcludedLicences(ctx context.Context, userID string, licences []string) error
}
// Rule words, matched by the router.
const (
licenceRuleNonCommercial = "rule:non-commercial"
licenceRuleRegion = "rule:region:"
)
var regionCode = regexp.MustCompile(`^[A-Z]{2}$`)
// SetLicences offers the choice of licences on the profile page: what the
// catalog's models are under, and where the choices are kept.
func (p *AccountPages) SetLicences(choices func() LicenceChoices, prefs LicencePrefs) {
p.licenceChoices, p.licencePrefs = choices, prefs
}
// fillLicences puts the choice, and what the person has made of it, on the
// profile page; nothing when it is not offered.
func (p *AccountPages) fillLicences(ctx context.Context, userID string, d *profileData) {
if p.licenceChoices == nil || p.licencePrefs == nil {
return
}
c := p.licenceChoices()
ex, err := p.licencePrefs.UserExcludedLicences(ctx, userID)
if err != nil {
p.log.Error("could not read a person's licence choices", "err", err)
}
for i := range c.Licences {
c.Licences[i].Excluded = slices.ContainsFunc(ex, func(e string) bool { return strings.EqualFold(e, c.Licences[i].Key) })
}
for _, e := range ex {
switch {
case e == licenceRuleNonCommercial:
c.NonCommercial = true
case strings.HasPrefix(e, licenceRuleRegion):
c.ExcludedRegions = append(c.ExcludedRegions, strings.TrimPrefix(e, licenceRuleRegion))
}
}
// A region the person excluded that no licence in the catalog names yet
// is still offered, ticked: it is there for the model added next.
for _, reg := range c.ExcludedRegions {
if !slices.Contains(c.Regions, reg) {
c.Regions = append(c.Regions, reg)
}
}
slices.Sort(c.Regions)
d.Licences = &c
}
func (p *AccountPages) setLicences(w http.ResponseWriter, r *http.Request) {
noStore(w)
sess, user, ok := p.session(r)
if !ok {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if p.licencePrefs == nil {
http.NotFound(w, r)
return
}
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
var ex []string
for _, k := range r.Form["licence"] {
if k = strings.TrimSpace(k); k != "" && !strings.HasPrefix(k, "rule:") {
ex = append(ex, strings.ToLower(k))
}
}
if r.FormValue("non_commercial") != "" {
ex = append(ex, licenceRuleNonCommercial)
}
regions := r.Form["region"]
regions = append(regions, strings.FieldsFunc(r.FormValue("regions_other"), func(c rune) bool {
return c == ',' || c == ' '
})...)
l := langOf(r)
for _, reg := range regions {
reg = strings.ToUpper(strings.TrimSpace(reg))
if reg == "" {
continue
}
if !regionCode.MatchString(reg) {
p.render(w, r, sess, user, profileData{LicenceError: l.T("profile.licences.err_region", reg)})
return
}
ex = append(ex, licenceRuleRegion+reg)
}
if err := p.licencePrefs.SetUserExcludedLicences(r.Context(), user.ID, ex); err != nil {
p.log.Error("could not keep a person's licence choices", "err", err)
p.render(w, r, sess, user, profileData{LicenceError: l.T("profile.err.try_again")})
return
}
p.render(w, r, sess, user, profileData{LicencesSaved: true})
}
+84
View File
@@ -0,0 +1,84 @@
package web
import (
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func TestTheProfileKeepsTheLicencesAPersonLeavesOut(t *testing.T) {
ctx := t.Context()
st, err := store.OpenSQLite(ctx, filepath.Join(t.TempDir(), "l.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
ada := store.User{ID: "usr-a", Email: "ada@example.com", State: store.UserActive, CreatedAt: time.Now()}
if err := st.CreateUser(ctx, ada); err != nil {
t.Fatal(err)
}
p := NewAccountPages(fakeAccounts{user: ada}, st, nil, "v0", slog.New(slog.DiscardHandler))
p.SetLicences(func() LicenceChoices {
return LicenceChoices{
Licences: []LicenceChoice{
{Key: "mit", Name: "MIT", Commercial: "yes", Models: []string{"triposr"}},
{Key: "tencent-hunyuan-community", Name: "Tencent Hunyuan Community",
ExcludedRegions: []string{"EU", "UK", "KR"}, Models: []string{"hunyuan3d"}},
},
Regions: []string{"EU", "KR", "UK"},
}
}, st)
mux := http.NewServeMux()
p.Register(mux)
do := func(method string, form url.Values) string {
var req *http.Request
if form == nil {
req = httptest.NewRequest(method, "/profile", nil)
} else {
form.Set("csrf", "tok")
req = httptest.NewRequest(method, "/profile/licences", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: "good"})
req.AddCookie(&http.Cookie{Name: csrfCookie, Value: "tok"})
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec.Body.String()
}
page := do(http.MethodGet, nil)
for _, want := range []string{`action="/profile/licences"`, "Tencent Hunyuan Community", "not in EU, UK, KR", "hunyuan3d"} {
if !strings.Contains(page, want) {
t.Errorf("the page lacks %q", want)
}
}
saved := do(http.MethodPost, url.Values{"region": {"EU"}, "regions_other": {"ch, NO"}, "licence": {"MIT"},
"non_commercial": {"1"}})
got, _ := st.UserExcludedLicences(ctx, ada.ID)
want := []string{"mit", "rule:non-commercial", "rule:region:CH", "rule:region:EU", "rule:region:NO"}
if !slices.Equal(got, want) || !strings.Contains(saved, "Saved.") {
t.Errorf("kept %v, want %v", got, want)
}
// What was kept is what the page shows ticked, a region no licence
// names yet included.
if !strings.Contains(saved, `value="CH" checked`) || !strings.Contains(saved, `value="mit" checked`) {
t.Error("the page does not show what was kept")
}
bad := do(http.MethodPost, url.Values{"regions_other": {"Europe"}})
if !strings.Contains(bad, "is not a region code") {
t.Error("a bad region was not refused")
}
if got2, _ := st.UserExcludedLicences(ctx, ada.ID); !slices.Equal(got2, want) {
t.Errorf("a refused form changed what was kept: %v", got2)
}
}
+16
View File
@@ -143,6 +143,22 @@
"profile.new_password": "New password",
"profile.change_password": "Change password",
"profile.password_note": "Changing it signs out every other browser, which is the point if\nyou are changing it because something went wrong.",
"profile.licences": "Licences",
"profile.licences.intro": "Models under a licence you leave out here are not listed to you and do not answer your keys or this portal — for example, nothing made under a licence that excludes where your product is sold.",
"profile.licences.rules": "Leave out every licence that",
"profile.licences.non_commercial": "forbids commercial use",
"profile.licences.region": "grants nothing in %s",
"profile.licences.regions_other": "Also regions not listed (ISO codes, EU for the union)",
"profile.licences.regions_example": "EU, UK",
"profile.licences.each": "Or these licences",
"profile.licences.unspecified": "No licence stated",
"profile.licences.commercial_yes": "commercial use allowed",
"profile.licences.commercial_no": "non-commercial",
"profile.licences.not_in": "not in %s",
"profile.licences.save": "Save",
"profile.licences.saved": "Saved. Your keys follow it within a minute.",
"profile.licences.err_region": "%q is not a region code: two capital letters, or EU",
"profile.licences.note": "The rules also cover models added later. A licence that says nothing about commercial use or regions is not caught by them; tick it here if you have not checked it.",
"profile.err.personal_off": "personal keys are off on this gateway; choose a project",
"profile.err.try_again": "something went wrong; try again",
"profile.err.not_member": "you are not a member of that project",
+16
View File
@@ -143,6 +143,22 @@
"profile.new_password": "Yeni parola",
"profile.change_password": "Parolayı değiştir",
"profile.password_note": "Parolayı değiştirmek diğer tüm tarayıcılardaki oturumları kapatır; bir sorun\nolduğu için değiştiriyorsanız amaç da zaten budur.",
"profile.licences": "Lisanslar",
"profile.licences.intro": "Burada dışarıda bıraktığınız lisanslardaki modeller size listelenmez, anahtarlarınıza ve bu portala yanıt vermez — örneğin ürününüzün satıldığı bölgeyi dışlayan bir lisansla üretilmiş hiçbir şey.",
"profile.licences.rules": "Şu lisansların hepsini dışarıda bırak",
"profile.licences.non_commercial": "ticari kullanıma izin vermeyenler",
"profile.licences.region": "%s bölgesinde hak tanımayanlar",
"profile.licences.regions_other": "Listede olmayan bölgeler de (ISO kodları, birlik için EU)",
"profile.licences.regions_example": "EU, UK",
"profile.licences.each": "Ya da şu lisanslar",
"profile.licences.unspecified": "Lisansı belirtilmemiş",
"profile.licences.commercial_yes": "ticari kullanım serbest",
"profile.licences.commercial_no": "ticari değil",
"profile.licences.not_in": "%s dışında",
"profile.licences.save": "Kaydet",
"profile.licences.saved": "Kaydedildi. Anahtarlarınız bir dakika içinde buna uyar.",
"profile.licences.err_region": "%q bir bölge kodu değil: iki büyük harf ya da EU",
"profile.licences.note": "Kurallar sonradan eklenen modelleri de kapsar. Ticari kullanım ya da bölge hakkında bir şey söylemeyen lisanslar kurallara takılmaz; kontrol etmediyseniz onları burada işaretleyin.",
"profile.err.personal_off": "bu ağ geçidinde kişisel anahtarlar kapalı; bir proje seçin",
"profile.err.try_again": "bir sorun oluştu; yeniden deneyin",
"profile.err.not_member": "o projenin üyesi değilsiniz",
+24
View File
@@ -200,6 +200,30 @@ document.addEventListener("click", (e) => {
{{else}}<p class="empty">{{t "selfuse.nowhere"}}</p>{{end}}
<p class="note">{{t "selfuse.strictest"}}</p>
{{with .Licences}}
<h2 id="licences">{{t "profile.licences"}}</h2>
{{if $.LicencesSaved}}<p>{{t "profile.licences.saved"}}</p>{{end}}
{{if $.LicenceError}}<p class="error">{{$.LicenceError}}</p>{{end}}
<p class="note">{{t "profile.licences.intro"}}</p>
<form method="post" action="/profile/licences" class="narrow">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<fieldset><legend>{{t "profile.licences.rules"}}</legend>
<label class="check"><input type="checkbox" name="non_commercial" value="1"{{if .NonCommercial}} checked{{end}}>
{{t "profile.licences.non_commercial"}}</label>
{{range .Regions}}<label class="check"><input type="checkbox" name="region" value="{{.}}"{{if has $.Licences.ExcludedRegions .}} checked{{end}}>
{{t "profile.licences.region" .}}</label>{{end}}
<label>{{t "profile.licences.regions_other"}}<input type="text" name="regions_other" placeholder="{{t "profile.licences.regions_example"}}" autocomplete="off"></label>
</fieldset>
<fieldset><legend>{{t "profile.licences.each"}}</legend>
{{range .Licences}}<label class="check"><input type="checkbox" name="licence" value="{{.Key}}"{{if .Excluded}} checked{{end}}>
{{if .Name}}{{if .URL}}<a href="{{.URL}}" rel="noopener noreferrer">{{.Name}}</a>{{else}}{{.Name}}{{end}}{{else}}{{t "profile.licences.unspecified"}}{{end}}
<span class="opt">{{if eq .Commercial "yes"}}{{t "profile.licences.commercial_yes"}} · {{else if eq .Commercial "no"}}{{t "profile.licences.commercial_no"}} · {{end}}{{with .ExcludedRegions}}{{t "profile.licences.not_in" (join .)}} · {{end}}{{join .Models}}</span></label>{{end}}
</fieldset>
<button type="submit">{{t "profile.licences.save"}}</button>
</form>
<p class="note">{{t "profile.licences.note"}}</p>
{{end}}
<h2>{{t "profile.password"}}</h2>
{{if .PasswordChanged}}<p>{{t "profile.password_changed"}}</p>{{end}}
{{if .PasswordError}}<p class="error">{{.PasswordError}}</p>{{end}}