diff --git a/README.md b/README.md index 39e76c029..cd7ab1aa5 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,10 @@ an LRU response cache, and per-visitor + global daily token budgets. See [docs/ai-plan](docs/ai-plan/index.md) for the phased plan and [docs/agents.md](docs/agents.md) for the agent quickstart. +AI features require the initiating account owner's explicit consent to the +configured service. Review or revoke it in **Settings → AI privacy**; see +[AI privacy and consent](docs/privacy-ai.md) for data flows and upgrade behavior. + ## Install Prebuilt static Linux binaries (amd64 + arm64, web app and docs embedded) are on the diff --git a/ai/accounting_regression_test.go b/ai/accounting_regression_test.go new file mode 100644 index 000000000..40ba46faa --- /dev/null +++ b/ai/accounting_regression_test.go @@ -0,0 +1,79 @@ +package ai + +import ( + "context" + "fmt" + "math" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestClient_BudgetReservesInFlightCalls(t *testing.T) { + c := newTestClient(t, &Config{Provider: "mock", VisitorDailyTokenBudget: 10, GlobalDailyTokenBudget: 10}) + started := make(chan struct{}, 2) + release := make(chan struct{}) + c.Mock().SetHandler(func(req *Request) (*Response, error) { + started <- struct{}{} + <-release + return &Response{Text: "answer", OutputTokens: 10}, nil + }) + first := make(chan error, 1) + go func() { + _, err := c.Complete(context.Background(), &Request{Feature: FeatureChat, MaxTokens: 10, UserKey: "one"}) + first <- err + }() + <-started + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + _, err := c.Complete(ctx, &Request{Feature: FeatureDigest, MaxTokens: 10, UserKey: "two"}) + close(release) + require.NoError(t, <-first) + require.ErrorIs(t, err, ErrBudgetExceeded) + require.Equal(t, int64(10), c.Report("one").Global.Total()) +} + +func TestEmbedder_CallsCannotBypassBudget(t *testing.T) { + c := newTestClient(t, &Config{Provider: "mock", GlobalDailyTokenBudget: 1}) + c.budget.Record("user:one", 1, 0) + _, err := c.Embedder("model").Embed(context.Background(), []string{"uncached"}) + require.ErrorIs(t, err, ErrBudgetExceeded) + require.Equal(t, 0, c.Mock().EmbedCalls()) +} + +func TestBudget_NegativeAndOverflowUsageCannotReopenBudget(t *testing.T) { + b := NewBudget(10, 10) + b.Record("one", math.MaxInt64, math.MaxInt64) + b.Record("one", -100, -100) + require.ErrorIs(t, b.Allow("one"), ErrBudgetExceeded) + require.GreaterOrEqual(t, b.Report("one").Global.Total(), int64(10)) +} + +func TestBudget_ReservationSurvivesMidnightAndFinishesOnce(t *testing.T) { + now := time.Date(2026, 10, 13, 23, 59, 59, 0, time.UTC) + b := NewBudget(10, 10) + b.now = func() time.Time { return now } + first, err := b.Reserve("one", 8) + require.NoError(t, err) + now = now.Add(2 * time.Second) + _, err = b.Reserve("two", 3) + require.ErrorIs(t, err, ErrBudgetExceeded, "a pending call must not disappear at midnight") + first.Finish(3, 2) + first.Finish(3, 2) + second, err := b.Reserve("two", 5) + require.NoError(t, err) + second.Finish(1, 0) + require.Equal(t, int64(6), b.Report("one").Global.Total()) +} + +func TestEmbedder_ChargesProviderReportedUsage(t *testing.T) { + c, srv := streamingClient(t, func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"data":[{"index":0,"embedding":[1,2]}],"usage":{"total_tokens":27}}`) + }, time.Second) + defer srv.Close() + _, err := c.Embedder("model").Embed(context.Background(), []string{"one"}, "user:one") + require.NoError(t, err) + require.Equal(t, int64(27), c.Report("user:one").Visitor.InputTokens) +} diff --git a/ai/ai.go b/ai/ai.go index b9dd64cd9..e6b228896 100644 --- a/ai/ai.go +++ b/ai/ai.go @@ -92,6 +92,22 @@ func (c *Client) ProviderName() string { return c.provider.Name() } +// ProviderBaseURL returns the internal endpoint for consent identity/disclosure. +// Callers must remove credentials, path and query before exposing it to clients. +func (c *Client) ProviderBaseURL() string { + if !c.Enabled() { + return "" + } + switch p := c.provider.(type) { + case *openAIProvider: + return p.baseURL + case *anthropicProvider: + return p.baseURL + default: + return "" // The mock provider has no network endpoint. + } +} + // DefaultModel returns the default model, or the provider's default if unset. func (c *Client) DefaultModel() string { if !c.Enabled() { @@ -112,6 +128,11 @@ func (c *Client) Complete(ctx context.Context, req *Request) (*Response, error) if !c.Enabled() { return nil, ErrDisabled } + request := *req + req = &request + if req.MaxTokens <= 0 { + req.MaxTokens = 1024 + } if req.Model == "" { req.Model = c.modelFor(req.Feature) } @@ -120,10 +141,13 @@ func (c *Client) Complete(ctx context.Context, req *Request) (*Response, error) metrics.AICacheHits.WithLabelValues(string(req.Feature)).Inc() return cached, nil } - if err := c.budget.Allow(req.UserKey); err != nil { + reservation, err := c.budget.Reserve(req.UserKey, requestInputTokens(req)+int64(req.MaxTokens)) + if err != nil { metrics.AIRequestsFailure.WithLabelValues(string(req.Feature)).Inc() return nil, err } + input, output := requestInputTokens(req), int64(0) + defer func() { reservation.Finish(input, output) }() callCtx, cancel := context.WithTimeout(ctx, c.timeout) defer cancel() start := time.Now() @@ -133,10 +157,19 @@ func (c *Client) Complete(ctx context.Context, req *Request) (*Response, error) metrics.AIRequestsFailure.WithLabelValues(string(req.Feature)).Inc() return nil, err } + if response == nil || response.InputTokens < 0 || response.OutputTokens < 0 { + return nil, ErrInvalidResponse + } + input, output = response.InputTokens, response.OutputTokens + if input == 0 && output == 0 { + input = requestInputTokens(req) + output = int64((len(response.Text) + 3) / 4) + response.InputTokens = input + response.OutputTokens = output + } metrics.AIRequestsSuccess.WithLabelValues(string(req.Feature)).Inc() metrics.AITokensInput.WithLabelValues(string(req.Feature)).Add(float64(response.InputTokens)) metrics.AITokensOutput.WithLabelValues(string(req.Feature)).Add(float64(response.OutputTokens)) - c.budget.Record(req.UserKey, response.InputTokens, response.OutputTokens) c.cache.Store(req, response) return response, nil } @@ -173,12 +206,33 @@ func (c *Client) Mock() *MockProvider { // embedTexts computes embeddings via the provider's embeddings endpoint. Only // OpenAI-compatible providers implement it; others return ErrStreamingNotSupported-style // capability errors which callers map to keyword-only fallbacks. -func (c *Client) embedTexts(ctx context.Context, model string, texts []string) ([][]float32, error) { +func (c *Client) embedTexts(ctx context.Context, model string, texts []string, userKey string) ([][]float32, error) { te, ok := c.provider.(TextEmbedder) if !ok { return nil, ErrEmbeddingsNotSupported } - return te.EmbedTexts(ctx, model, texts) + var bytes int64 + for _, text := range texts { + bytes += int64(len(text)) + } + input := (bytes + 3) / 4 + reservation, err := c.budget.Reserve(userKey, input) + if err != nil { + return nil, err + } + defer func() { reservation.Finish(input, 0) }() + callCtx, cancel := context.WithTimeout(ctx, c.timeout) + defer cancel() + if metered, ok := te.(interface { + embedTextsWithUsage(context.Context, string, []string) ([][]float32, int64, error) + }); ok { + vectors, tokens, err := metered.embedTextsWithUsage(callCtx, model, texts) + if tokens > 0 { + input = tokens + } + return vectors, err + } + return te.EmbedTexts(callCtx, model, texts) } // modelFor resolves the per-feature model override, falling back to the default model. diff --git a/ai/ai_test.go b/ai/ai_test.go index aeb5f64db..b60423e1b 100644 --- a/ai/ai_test.go +++ b/ai/ai_test.go @@ -61,15 +61,15 @@ func TestClient_BudgetEnforced(t *testing.T) { mock.SetHandler(func(req *Request) (*Response, error) { return &Response{Text: "ok", InputTokens: 60, OutputTokens: 50}, nil }) - _, err := client.Complete(context.Background(), &Request{Feature: FeatureChat, Prompt: "1", UserKey: "user:phil"}) + _, err := client.Complete(context.Background(), &Request{Feature: FeatureChat, Prompt: "1", UserKey: "user:phil", MaxTokens: 1}) require.Nil(t, err) // 110 tokens, over visitor budget of 100 — charged after the fact - _, err = client.Complete(context.Background(), &Request{Feature: FeatureChat, Prompt: "2", UserKey: "user:phil"}) + _, err = client.Complete(context.Background(), &Request{Feature: FeatureChat, Prompt: "2", UserKey: "user:phil", MaxTokens: 1}) require.ErrorIs(t, err, ErrBudgetExceeded) // Other visitors can still spend until the global budget is hit (110/150 used) - _, err = client.Complete(context.Background(), &Request{Feature: FeatureChat, Prompt: "3", UserKey: "user:ben"}) + _, err = client.Complete(context.Background(), &Request{Feature: FeatureChat, Prompt: "3", UserKey: "user:ben", MaxTokens: 1}) require.Nil(t, err) // global now at 220 - _, err = client.Complete(context.Background(), &Request{Feature: FeatureChat, Prompt: "4", UserKey: "user:ben"}) + _, err = client.Complete(context.Background(), &Request{Feature: FeatureChat, Prompt: "4", UserKey: "user:ben", MaxTokens: 1}) require.ErrorIs(t, err, ErrBudgetExceeded) report := client.Report("user:phil") diff --git a/ai/budget.go b/ai/budget.go index a1afeac32..16a757122 100644 --- a/ai/budget.go +++ b/ai/budget.go @@ -2,6 +2,7 @@ package ai import ( "fmt" + "math" "sync" "time" ) @@ -13,11 +14,13 @@ type Budget struct { visitorDaily int64 // Max tokens per visitor per day; 0 = unlimited globalDaily int64 // Max tokens server-wide per day; 0 = unlimited - mu sync.Mutex - day string // UTC day the counters belong to, e.g. "2026-09-24" - global Usage // Global usage for today - perVisitor map[string]*Usage // Per-visitor usage for today - now func() time.Time // Injectable clock for tests + mu sync.Mutex + day string // UTC day the counters belong to, e.g. "2026-09-24" + global Usage // Global usage for today + reserved int64 + visitorReserved map[string]int64 + perVisitor map[string]*Usage // Per-visitor usage for today + now func() time.Time // Injectable clock for tests } // Usage is a token/request counter for one day. @@ -28,7 +31,7 @@ type Usage struct { } // Total returns input+output tokens. -func (u *Usage) Total() int64 { return u.InputTokens + u.OutputTokens } +func (u *Usage) Total() int64 { return saturatingAdd(u.InputTokens, u.OutputTokens) } // UsageReport is the API-facing view of today's usage. type UsageReport struct { @@ -42,17 +45,18 @@ type UsageReport struct { // NewBudget creates a Budget. Budget values of 0 mean unlimited. func NewBudget(visitorDaily, globalDaily int64) *Budget { return &Budget{ - visitorDaily: visitorDaily, - globalDaily: globalDaily, - perVisitor: make(map[string]*Usage), - now: time.Now, + visitorDaily: visitorDaily, + globalDaily: globalDaily, + perVisitor: make(map[string]*Usage), + visitorReserved: make(map[string]int64), + now: time.Now, } } // Allow returns ErrBudgetExceeded if the visitor or the global daily budget is already // exhausted. It does not reserve tokens; Record charges actual usage afterwards, so a -// single in-flight request may overshoot its budget. This is deliberate: estimation -// would either throttle legitimate traffic or be gameable. +// callers performing provider work use Reserve to include in-flight cost. Actual +// provider usage can exceed the input-token estimate; subsequent calls are blocked. func (b *Budget) Allow(userKey string) error { b.mu.Lock() defer b.mu.Unlock() @@ -73,9 +77,13 @@ func (b *Budget) Record(userKey string, input, output int64) { b.mu.Lock() defer b.mu.Unlock() b.rollover() - b.global.Requests++ - b.global.InputTokens += input - b.global.OutputTokens += output + b.record(userKey, input, output) +} + +func (b *Budget) record(userKey string, input, output int64) { + b.global.Requests = saturatingAdd(b.global.Requests, 1) + b.global.InputTokens = saturatingAdd(b.global.InputTokens, input) + b.global.OutputTokens = saturatingAdd(b.global.OutputTokens, output) if userKey == "" { return } @@ -84,9 +92,9 @@ func (b *Budget) Record(userKey string, input, output int64) { u = &Usage{} b.perVisitor[userKey] = u } - u.Requests++ - u.InputTokens += input - u.OutputTokens += output + u.Requests = saturatingAdd(u.Requests, 1) + u.InputTokens = saturatingAdd(u.InputTokens, input) + u.OutputTokens = saturatingAdd(u.OutputTokens, output) } // Report returns today's usage for a visitor key and the configured budgets. @@ -118,3 +126,62 @@ func (b *Budget) rollover() { b.global = Usage{} b.perVisitor = make(map[string]*Usage) } + +// Reservation holds an estimated input + maximum output charge while a provider +// call is in flight. Finish releases it and records actual (or estimated) usage. +type Reservation struct { + budget *Budget + key string + tokens int64 + done bool +} + +// Reserve checks completed and in-flight usage atomically. It never calls a provider. +func (b *Budget) Reserve(key string, tokens int64) (*Reservation, error) { + b.mu.Lock() + defer b.mu.Unlock() + b.rollover() + tokens = max(0, tokens) + if b.globalDaily > 0 && (b.global.Total() >= b.globalDaily || b.reserved > b.globalDaily-b.global.Total() || tokens > b.globalDaily-b.global.Total()-b.reserved) { + return nil, fmt.Errorf("%w: global daily budget", ErrBudgetExceeded) + } + if key != "" && b.visitorDaily > 0 { + used := int64(0) + if u := b.perVisitor[key]; u != nil { + used = u.Total() + } + pending := b.visitorReserved[key] + if used >= b.visitorDaily || pending > b.visitorDaily-used || tokens > b.visitorDaily-used-pending { + return nil, fmt.Errorf("%w: visitor daily budget", ErrBudgetExceeded) + } + } + b.reserved = saturatingAdd(b.reserved, tokens) + b.visitorReserved[key] = saturatingAdd(b.visitorReserved[key], tokens) + return &Reservation{budget: b, key: key, tokens: tokens}, nil +} + +// Finish releases a reservation once, including when the UTC day rolled over. +func (r *Reservation) Finish(input, output int64) { + b := r.budget + b.mu.Lock() + defer b.mu.Unlock() + if r.done { + return + } + r.done = true + b.rollover() + b.reserved -= r.tokens + b.visitorReserved[r.key] -= r.tokens + if b.visitorReserved[r.key] == 0 { + delete(b.visitorReserved, r.key) + } + b.record(r.key, input, output) +} + +func saturatingAdd(a, b int64) int64 { + a, b = max(0, a), max(0, b) + if a > math.MaxInt64-b { + return math.MaxInt64 + } + return a + b +} diff --git a/ai/digest.go b/ai/digest.go index 688fcc142..868ef96ed 100644 --- a/ai/digest.go +++ b/ai/digest.go @@ -193,7 +193,7 @@ func (d *Digester) Digest(ctx context.Context, userKey string, in *DigestInput) } fmt.Fprintf(&prompt, "):\n\n") if d.embedder != nil && len(messages) >= 3 { - prompt.WriteString(buildClusterHints(d.embedder, messages)) + prompt.WriteString(buildClusterHints(ctx, d.embedder, messages, userKey)) } for _, m := range messages { timestamp := time.Unix(m.Time, 0).UTC().Format(time.RFC3339) @@ -276,12 +276,12 @@ func parseDigest(topic string, messageCount int, text string) (*DigestResult, er // buildClusterHints computes semantic clusters and renders a short hint block for the // prompt. Embedding failure degrades silently to no hints (plain summarization). -func buildClusterHints(e *Embedder, messages []DigestMessage) string { +func buildClusterHints(ctx context.Context, e *Embedder, messages []DigestMessage, userKey string) string { texts := make([]string, len(messages)) for i, m := range messages { texts[i] = m.Title + "\n" + m.Message } - vectors, err := e.Embed(context.Background(), texts) + vectors, err := e.Embed(ctx, texts, userKey) if err != nil { return "" } diff --git a/ai/digest_cluster_test.go b/ai/digest_cluster_test.go index 41b03981a..f6a18178d 100644 --- a/ai/digest_cluster_test.go +++ b/ai/digest_cluster_test.go @@ -1,6 +1,7 @@ package ai import ( + "context" "testing" "github.com/stretchr/testify/require" @@ -34,7 +35,7 @@ func TestBuildClusterHints(t *testing.T) { {ID: "m4", Message: "totally different subject entirely"}, {ID: "m5", Message: "another unrelated subject here"}, } - hints := buildClusterHints(embedder, messages) + hints := buildClusterHints(context.Background(), embedder, messages, "") t.Logf("hints: %q", hints) // The deterministic mock vectors may or may not split these into 2+ groups; // the function must never panic and must never fabricate message numbers. @@ -47,6 +48,6 @@ func TestBuildClusterHints_EmptyOnError(t *testing.T) { return nil, ErrInvalidResponse }) embedder := client.Embedder("embed-model") - hints := buildClusterHints(embedder, []DigestMessage{{Message: "x"}, {Message: "y"}}) + hints := buildClusterHints(context.Background(), embedder, []DigestMessage{{Message: "x"}, {Message: "y"}}, "") require.Equal(t, "", hints) } diff --git a/ai/embeddings.go b/ai/embeddings.go index a1cf0ca98..3db0170b1 100644 --- a/ai/embeddings.go +++ b/ai/embeddings.go @@ -46,7 +46,11 @@ func (c *Client) Embedder(model string) *Embedder { // Embed returns one vector per input text, in input order. Uncached texts are sent to // the provider in batches of EmbedBatchSize; repeated identical texts are served from // the cache. -func (e *Embedder) Embed(ctx context.Context, texts []string) ([][]float32, error) { +func (e *Embedder) Embed(ctx context.Context, texts []string, userKeys ...string) ([][]float32, error) { + userKey := "" + if len(userKeys) > 0 { + userKey = userKeys[0] + } out := make([][]float32, len(texts)) var missingIdx []int var missing []string @@ -66,7 +70,7 @@ func (e *Embedder) Embed(ctx context.Context, texts []string) ([][]float32, erro if end > len(missing) { end = len(missing) } - part, err := e.client.embedTexts(ctx, e.model, missing[start:end]) + part, err := e.client.embedTexts(ctx, e.model, missing[start:end], userKey) if err != nil { return nil, err } diff --git a/ai/enrich.go b/ai/enrich.go index 6aed089a7..68254c418 100644 --- a/ai/enrich.go +++ b/ai/enrich.go @@ -43,6 +43,7 @@ type Enrichment struct { // EnrichRequest is the input for Enrich. TranslateTo (a language name or code, e.g. // "de" or "German") optionally requests a translation of the message body. type EnrichRequest struct { + UserKey string // Publisher attribution for token budgets Topic string Title string Message string @@ -90,6 +91,7 @@ func (e *Enricher) Enrich(ctx context.Context, req *EnrichRequest) (*Enrichment, } request := &Request{ Feature: FeatureEnrich, + UserKey: req.UserKey, System: fmt.Sprintf(system, EnrichmentMaxSummaryChars), Prompt: user.String(), JSONSchema: EnrichmentSchema, diff --git a/ai/openai.go b/ai/openai.go index c56eea881..939008e24 100644 --- a/ai/openai.go +++ b/ai/openai.go @@ -287,19 +287,24 @@ func (p *openAIProvider) Stream(ctx context.Context, req *Request) (<-chan Strea // EmbedTexts computes embeddings via the OpenAI-compatible /embeddings endpoint // (OpenAI, Ollama with embedding models, Poolside, ...). func (p *openAIProvider) EmbedTexts(ctx context.Context, model string, texts []string) ([][]float32, error) { + vectors, _, err := p.embedTextsWithUsage(ctx, model, texts) + return vectors, err +} + +func (p *openAIProvider) embedTextsWithUsage(ctx context.Context, model string, texts []string) ([][]float32, int64, error) { if len(texts) == 0 { - return nil, nil + return nil, 0, nil } body, err := json.Marshal(struct { Model string `json:"model"` Input []string `json:"input"` }{Model: model, Input: texts}) if err != nil { - return nil, err + return nil, 0, err } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/embeddings", bytes.NewReader(body)) if err != nil { - return nil, err + return nil, 0, err } httpReq.Header.Set("Content-Type", "application/json") if p.apiKey != "" { @@ -307,21 +312,24 @@ func (p *openAIProvider) EmbedTexts(ctx context.Context, model string, texts []s } httpResp, err := p.httpClient.Do(httpReq) if err != nil { - return nil, err + return nil, 0, err } defer httpResp.Body.Close() if httpResp.StatusCode != http.StatusOK { _, _ = io.Copy(io.Discard, io.LimitReader(httpResp.Body, 4096)) - return nil, &ProviderError{Provider: p.name, Status: httpResp.StatusCode, Message: httpResp.Status} + return nil, 0, &ProviderError{Provider: p.name, Status: httpResp.StatusCode, Message: httpResp.Status} } var embResp struct { + Usage struct { + Total int64 `json:"total_tokens"` + } `json:"usage"` Data []struct { Index int `json:"index"` Embedding []float32 `json:"embedding"` } `json:"data"` } - if err := json.NewDecoder(httpResp.Body).Decode(&embResp); err != nil { - return nil, fmt.Errorf("invalid embeddings response: %w", err) + if err := json.NewDecoder(io.LimitReader(httpResp.Body, 32*1024*1024)).Decode(&embResp); err != nil { + return nil, 0, fmt.Errorf("invalid embeddings response: %w", err) } out := make([][]float32, len(texts)) for _, item := range embResp.Data { @@ -329,7 +337,10 @@ func (p *openAIProvider) EmbedTexts(ctx context.Context, model string, texts []s out[item.Index] = item.Embedding } } - return out, nil + if embResp.Usage.Total < 0 { + return nil, 0, ErrInvalidResponse + } + return out, embResp.Usage.Total, nil } func (p *openAIProvider) Ping(ctx context.Context) error { diff --git a/ai/stream.go b/ai/stream.go index 83a89baf0..ac16ec377 100644 --- a/ai/stream.go +++ b/ai/stream.go @@ -2,6 +2,7 @@ package ai import ( "context" + "encoding/json" "fmt" "heckel.io/ntfy/v2/metrics" @@ -31,6 +32,9 @@ func (c *Client) CompleteStream(ctx context.Context, req *Request) (<-chan Strea return nil, ErrDisabled } request := *req // Callers may reuse requests concurrently. + if request.MaxTokens <= 0 { + request.MaxTokens = 1024 + } if request.Model == "" { request.Model = c.modelFor(request.Feature) } @@ -38,13 +42,15 @@ func (c *Client) CompleteStream(ctx context.Context, req *Request) (<-chan Strea if !ok { return nil, ErrStreamingNotSupported } - if err := c.budget.Allow(request.UserKey); err != nil { + reservation, err := c.budget.Reserve(request.UserKey, requestInputTokens(&request)+int64(request.MaxTokens)) + if err != nil { return nil, err } callCtx, cancel := context.WithTimeout(ctx, c.timeout) events, err := streamer.Stream(callCtx, &request) if err != nil { cancel() + reservation.Finish(0, 0) metrics.AIRequestsFailure.WithLabelValues(string(request.Feature)).Inc() return nil, err } @@ -58,10 +64,10 @@ func (c *Client) CompleteStream(ctx context.Context, req *Request) (<-chan Strea success := false defer func() { output := (outputBytes + 3) / 4 - if reported != nil { + if reported != nil && reported.Total() > 0 { input, output = reported.InputTokens, reported.OutputTokens } - c.budget.Record(request.UserKey, input, output) + reservation.Finish(input, output) metrics.AITokensInput.WithLabelValues(string(request.Feature)).Add(float64(max(0, input))) metrics.AITokensOutput.WithLabelValues(string(request.Feature)).Add(float64(max(0, output))) if success { @@ -103,6 +109,10 @@ func (c *Client) CompleteStream(ctx context.Context, req *Request) (<-chan Strea return } if event.Usage != nil { + if event.Usage.InputTokens < 0 || event.Usage.OutputTokens < 0 { + fail(ErrInvalidResponse) + return + } reported = event.Usage } if event.Err != nil { @@ -135,6 +145,10 @@ func requestInputTokens(req *Request) int64 { } else { bytes += len(req.Prompt) } + if req.JSONSchema != nil { + schema, _ := json.Marshal(req.JSONSchema.Schema) + bytes += len(schema) + } return int64((bytes + 3) / 4) } diff --git a/cmd/token.go b/cmd/token.go index fdd01d56b..64365259f 100644 --- a/cmd/token.go +++ b/cmd/token.go @@ -52,7 +52,7 @@ Examples: ntfy token add --expires=2d phil # Create token for user phil which expires in 2 days ntfy token add -e "tuesday, 8pm" phil # Create token for user phil which expires next Tuesday ntfy token add -l backups phil # Create token for user phil with label "backups" - ntfy token add --scope=ai phil # axon: token restricted to AI features only`, + ntfy token add --scope=ai phil # axon: allow AI; block account/admin management`, }, { Name: "remove", diff --git a/db/db.go b/db/db.go index d2ec60ba8..b6904f584 100644 --- a/db/db.go +++ b/db/db.go @@ -52,11 +52,21 @@ func (d *DB) QueryRow(query string, args ...any) *sql.Row { return d.primary.DB.QueryRow(query, args...) } +// QueryRowContext delegates to the primary database with cancellation. +func (d *DB) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row { + return d.primary.DB.QueryRowContext(ctx, query, args...) +} + // Exec delegates to the primary database. func (d *DB) Exec(query string, args ...any) (sql.Result, error) { return d.primary.DB.Exec(query, args...) } +// ExecContext delegates to the primary database with cancellation. +func (d *DB) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) { + return d.primary.DB.ExecContext(ctx, query, args...) +} + // Begin delegates to the primary database. func (d *DB) Begin() (*sql.Tx, error) { return d.primary.DB.Begin() diff --git a/docs/agents.md b/docs/agents.md index 51bb7f2b4..55cd251cb 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -1,5 +1,9 @@ # AI agents (MCP) +AI tools require their account owner's current [AI data-sharing consent](privacy-ai.md). +The owner reviews the provider using account credentials or an unrestricted +token. Scoped agents cannot grant or revoke it. + axon ships a built-in [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server, so AI agents — Claude Desktop, Claude Code, IDE agents, scripts — can use your ntfy server as their notification transport: send push notifications to your phone, wait for replies, read @@ -75,12 +79,12 @@ claude mcp add ntfy -- ntfy mcp --server https://ntfy.example.com --token tk_... | `list_subscriptions` | token | List the account's synced subscriptions | | `digest_topic` | token | AI summary of one topic's recent messages | | `briefing` | token | AI summary across all of the account's topics ("what did I miss?") | -| `plan_subscription` | - | Turn a wish like "notify me when backups fail" into a subscription plan (AI layer required on the server) | +| `plan_subscription` | token | Turn a wish like "notify me when backups fail" into a subscription plan (AI layer required on the server) | ### Human-in-the-loop pattern The classic agent loop with axon: the agent publishes a message with -[action buttons](../publish.md#action-buttons) (`publish` + ntfy's `X-Actions` header), the +[action buttons](publish.md#action-buttons) (`publish` + ntfy's `X-Actions` header), the human taps **approve**/**deny** on their phone, and the button publishes back to a reply topic the agent is waiting on with `subscribe_wait`. That's the whole round trip — no webhooks, no public agent endpoint, no polling. @@ -89,13 +93,16 @@ webhooks, no public agent endpoint, no polling. - Create a dedicated access token for your agent (`ntfy token add`) and treat it as the agent's identity; revoke it to cut the agent off. Server-side AI token budgets - (`ai-visitor-daily-token-budget`) apply per token. + (`ai-visitor-daily-token-budget`) apply per account, including free accounts. - **Scoped tokens** (axon): give an agent only what it needs with - `ntfy token add --scope=ai phil`. A token created with any `--scope` list can *only* - perform the listed capabilities — `--scope=ai` allows the AI endpoints (plan, tune, - digest, chat) but denies nothing else by itself, while a token *without* the `ai` - scope can publish/subscribe but never invoke AI features. Tokens created without - `--scope` remain fully unrestricted (backwards compatible). + `ntfy token add --scope=ai phil`. The `ai` scope grants access to AI endpoints + (plan, tune, digest, chat and briefing); a non-empty scope list without `ai` + cannot invoke them. Normal publishing and subscribing still use topic ACLs: + `read` and `publish` are currently labels, not independent capability gates. + Scoped agents cannot manage accounts, tokens, devices, billing or admin settings, + and account responses omit token values. Tokens created without `--scope` remain + fully unrestricted (backwards compatible). Device tokens use their separate + device sandbox described below. - `plan_subscription` only *proposes* plans — subscribing, and everything else the agent does, should still be reviewed by a human (see the [fork plan](ai-plan/index.md), Principle 2). diff --git a/docs/ai-plan/upstream-sync.md b/docs/ai-plan/upstream-sync.md index 434941ccd..92d656b16 100644 --- a/docs/ai-plan/upstream-sync.md +++ b/docs/ai-plan/upstream-sync.md @@ -35,10 +35,11 @@ every merge (grep first, line numbers drift): - In-file edits inside upstream code are marked `// axon:` and kept minimal. - Never reorder or reformat upstream code opportunistically; keep diffs surgical. - The Go module path stays `heckel.io/ntfy/v2` (see plan Phase 0). -- **Database migrations:** axon currently owns migration `9→10` (token scopes). When - upstream claims 9→10 in a future release, renumber ours to the next free version - (`9→10` becomes `10→11`, etc.) and bump both `*CurrentSchemaVersion` constants; the - migration bodies are independent. Re-verify `user/manager_*_schema.go` on every merge. +- **Database migrations:** never renumber migrations applied to deployed databases. + Fork and upstream versions can refer to different schemas. Inspect actual schemas + and add forward migrations handling both histories. Preserve deployed migration + bodies, test upgrades on SQLite and PostgreSQL, and document backup/rollback. + Re-verify `user/manager_*_schema.go` on every merge. ## After every merge diff --git a/docs/audit-2026-10-13.md b/docs/audit-2026-10-13.md index 4fd9bc806..92efdaaab 100644 --- a/docs/audit-2026-10-13.md +++ b/docs/audit-2026-10-13.md @@ -960,3 +960,62 @@ Fingerprints identify the exact local evidence used. Logs are retained beside th | `fix-docker-source-context.log` | `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | Optional PostgreSQL and S3/object-storage tests depend on their service/environment configuration. PostgreSQL 17 is configured in PR CI; no local PostgreSQL or production S3 operation was claimed. + + +## Follow-up snapshot, 2026-10-13 + +This section supersedes earlier merge-status and missing-backport statements; +the earlier sections retain the original audit evidence and baseline. + +Main remains `7831282bb92759e382ad2f92f1077cdba642df4c` at this snapshot. +The original eleven review PRs were marked merged, but #39, #42 and #43 were +merged into feature branches after #37 had already been squashed into main. +Their budget reservations, request-principal isolation and scoped-token +restrictions therefore did not reach main. Re-running the regression tests on +main reproduced concurrent budget overruns, Alice/Bob/anonymous identity +mix-ups and scoped-token credential escalation. + +| Follow-up PR | Concrete result and verification | +| --- | --- | +| [#46](https://github.com/mptfire/axon/pull/46) | Restore the three missing security fixes directly to main. Local full race suite and GitHub build/test checks pass. | +| [#47](https://github.com/mptfire/axon/pull/47) | Replace vulnerable Router 6.30.4 with patched 7.18.4. The existing declarative routes have ten added regression cases; all 111 web tests pass. npm audit reports zero vulnerabilities on this branch. GitHub build/test checks pass. | +| [#48](https://github.com/mptfire/axon/pull/48) | Backport RFC 2047 client header encoding and Markdown media-type parameters/case handling. Upstream integration tests fail on main and pass after the patches with -race; GitHub build/test checks pass. | +| [#49](https://github.com/mptfire/axon/pull/49) | Implement the owner's selected explicit per-account AI consent policy, including disclosure, revocation, provider/model invalidation, all content entry points and migrations. Local full race/build/vet and 110 web tests passed before workspace disconnection; final branch checks and PostgreSQL verification are recorded on the PR. | + +Consent's regression `TestServer_AIConsent_DefaultDeniedEveryContentEndpoint` +failed on main: plan, tune, digest, chat, briefing and streaming each returned +200 and made six mock provider calls without consent. The gate requires zero +completion/embedding calls and HTTP 403/code 40302. Additional cases cover +cached-result revocation, owner/account isolation, scoped agents, stale +acceptance, cross-origin writes, model role changes, background scheduling, +inline publishers on a shared visitor, lookup failure and request throttling. + +The policy uses separate user tables and the primary connection, rather than +preferences or replicas. Migration 12→13 grants no account consent. Provider +identities use a persistent private HMAC key; the disclosure excludes URL +credentials, paths, queries and API keys. Changing provider, effective URL or +model selections invalidates consent. Key rotation alone does not. Old +binaries cannot open schema 13: back up before upgrading. See +[AI privacy and consent](privacy-ai.md). + +Seven previously missing upstream backports are already on current main: +bc1e879d, c3c7d6ef, 99d55175, e63ec41b, 2153f646, 11acdd2a and f08d46dd. +PR #48 adds a33508bb, 3e9fe994, e199f010 and fa9ff8c6. The release and image +workflows passed on current main; Docker remains unavailable locally. +Upstream main is still 0c617d1d (2026-10-12) and the latest release is v2.30.0 +(2026-10-12). The original 178-commit ancestry gap is historical; selective +backports must not be mistaken for a complete upstream merge. + +Remaining upstream candidates include additive user-statistic updates +ba2b0a62 and follow-up fixes, e50dfee8/ac512e96 batch-insert changes, +c38218ac SMTP LOGIN, 535e1546 Alertmanager end time and the wider clustering +integration. Original scanner-discovered Go/x/net security updates are already +on main. No new upstream security advisory is claimed without a reproduction. +Dependabot #28 (Google API) and #29 (i18next major) remain open; #30–33 were +closed without merging. Issue #27 is addressed by #49 and remains open until +that PR merges. + +All follow-up PRs target **main**. Merge #46 before #49 for a focused review. +#49 also contains the required restoration, so it cannot strand those fixes +on a stale feature base. #47 and #48 are independent. No main merge or +deployment was performed in this follow-up. diff --git a/docs/privacy-ai.md b/docs/privacy-ai.md new file mode 100644 index 000000000..3c53bd74a --- /dev/null +++ b/docs/privacy-ai.md @@ -0,0 +1,90 @@ +# AI privacy and account consent + +AI is off unless the operator sets `ai-enabled: true`. Enabling it on the server +does not grant consent for any account. Each account owner must review the +configured service and explicitly enable AI data sharing before using it. + +The web app shows this disclosure before opening an AI assistant. **Settings → +AI privacy** shows current consent and lets the owner revoke it. The disclosure +identifies the provider, its service origin and configured models, including +embeddings. URL credentials, path and query secrets are omitted. Operators should +separately explain their provider's processing and retention terms. Local Ollama +can keep model processing within the operator's environment; cloud providers +receive selected content outside that environment. + +## What leaves the server + +| Feature | Content sent to the configured service | +| --- | --- | +| Subscription setup/tuning | Your prompt or goal, prior conversation turns, subscription names and filter settings used as context | +| Enrichment/translation | The authenticated consenting publisher's message text, title and topic, plus the translation language | +| Digest/briefing | Selected messages and metadata from subscribed topics the requesting account can read | +| History chat | Your question and conversation history, plus selected readable messages and metadata used as evidence | +| Semantic retrieval/clustering | Text used to embed the question and selected messages when an embeddings model is configured | + +History can include content published by other people. Consent belongs to the +account initiating the AI feature; it does not imply every publisher's consent. +Operators must communicate this behavior and apply appropriate topic policies. +Consent does not expand access. Topic ACLs and model-output validation still apply. + +Consent covers AI-scoped agents, opted-in inline enrichment and enabled scheduled +briefings. Enrichment requires an authenticated publisher with AI permission and +current consent. Other publishes pass through unchanged. The separate daily +briefing setting still controls scheduling; absent consent, the scheduler skips +the account without marking that day's briefing as delivered. + +Revocation blocks new content requests, including response-cache hits. It cannot +recall content already sent, remove the provider's retained data or cancel work +already authorized and in progress. Server caches and stored delivered messages +retain their existing retention behavior. Ask the operator about those terms. + +## Provider changes and API clients + +Changing the provider, effective service URL or configured models invalidates +existing consent. The owner must review and accept the new configuration. +Rotating the provider API key alone does not require consent again. Configuration +identities are opaque HMAC values backed by a private persistent database key. +Clients must treat them as identifiers, not derive or interpret them. + +Use `GET /v1/ai/consent` to read the authenticated account's disclosure. After +presenting it to the owner, submit its `configuration` value with explicit +acceptance. For example, use an unrestricted account token: + +```sh +curl -H "Authorization: Bearer $AXON_ACCOUNT_TOKEN" \ + https://axon.example/v1/ai/consent + +# Replace the placeholder with the configuration value the owner reviewed. +curl -X POST -H "Authorization: Bearer $AXON_ACCOUNT_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"accepted":true,"configuration":"reviewed-configuration-value"}' \ + https://axon.example/v1/ai/consent + +curl -X DELETE -H "Authorization: Bearer $AXON_ACCOUNT_TOKEN" \ + https://axon.example/v1/ai/consent +``` + +Basic authentication also works. Scoped AI agents can read the disclosure and +use AI after their owner consents, but cannot grant or revoke it. Other scoped +tokens do not gain AI access. Browser writes require the server origin or the +operator's explicit trusted CORS origin; wildcard CORS does not authorize writes. +Acceptance requires JSON and the current configuration. + +Missing consent returns HTTP 403, code `40302`, before content transfer, +embeddings, cache lookup or AI quota charging. Stale acceptance returns HTTP 409, +code `40909`; refresh the disclosure and ask the owner to accept again. Database +lookup errors fail closed. Status/health and budget reports send no user content +and need no consent. With AI disabled, the consent endpoint is disabled too. + +## Upgrade and rollback + +User migration `12 → 13` creates separate consent and private metadata tables on +SQLite and PostgreSQL. It grants no existing account consent. Consent is read +from the primary so replica lag cannot restore a revoked grant. Ordinary account +preferences cannot set or restore it. The account primary key covers lookups. + +Back up the user database before upgrading. Migrations run transactionally and +are recorded by the existing schema manager. There is no down migration; older +binaries reject version 13. Rollback requires a compatible binary or restoring +the pre-upgrade database, losing intervening writes. Never renumber migrations +already applied to deployed databases. diff --git a/docs/security-audit.md b/docs/security-audit.md index 0ecd3db01..0980e9a84 100644 --- a/docs/security-audit.md +++ b/docs/security-audit.md @@ -283,3 +283,21 @@ Open items: issue #27 (AI consent/disclosure policy — product decision), two moderate react-router advisories (major migration deferred), upstream sync (178 commits; shutdown/Postgres/cluster fixes listed in the audit report), no down-migrations (rely on the daily backups before upgrades). + +## Addendum 6 — stacked PR integration gap + +A follow-up check of main at `7831282b` found that #39, #42 and #43 had been +merged into their feature-branch bases after #37 was squashed into main. +Their merged status did not mean the budget, request-principal and scoped-token +fixes were present on main. The Addendum 5 claim that all fixes were integrated +was therefore incorrect. + +The restoration change reapplies the reviewed patches together and restores +their regression tests. Before restoration, the same-IP account and token-scope +interleavings, unrestricted-token harvesting/minting, embedding-budget bypass +and publisher-attribution tests fail on main. They pass after restoration. + +For future stacks, after merging a parent into main, retarget the next child PR +to main and reconcile its patch with the merged result. Verify the resulting +main tree and run the security regressions; a merged feature-branch PR alone is +not evidence of integration. diff --git a/mkdocs.yml b/mkdocs.yml index b5d085321..16fe1ef31 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -110,6 +110,7 @@ nav: - "FAQs": faq.md - "Examples": examples.md - "AI agents (MCP)": agents.md + - "AI privacy and consent": privacy-ai.md - "Agent setup runbook": agent-setup.md - "Audit 2026-10-13 (external)": audit-2026-10-13.md - "Integrations + projects": integrations.md diff --git a/server/errors.go b/server/errors.go index f66a7d35f..0c0580167 100644 --- a/server/errors.go +++ b/server/errors.go @@ -153,6 +153,8 @@ var ( errHTTPBadRequestTagsTooLarge = &errHTTP{40058, http.StatusBadRequest, "invalid request: tags are too large", "https://ntfy.sh/docs/publish/#limitations", nil} errHTTPNotFound = &errHTTP{40401, http.StatusNotFound, "page not found", "", nil} errHTTPUnauthorized = &errHTTP{40101, http.StatusUnauthorized, "unauthorized", "https://ntfy.sh/docs/publish/#authentication", nil} + errHTTPForbiddenAIConsentRequired = &errHTTP{40302, http.StatusForbidden, "AI consent required; review AI privacy settings for your account", "", nil} + errHTTPConflictAIProviderChanged = &errHTTP{40909, http.StatusConflict, "AI provider configuration changed; review the updated disclosure before consenting", "", nil} errHTTPForbidden = &errHTTP{40301, http.StatusForbidden, "forbidden", "https://ntfy.sh/docs/publish/#authentication", nil} errHTTPBadRequestDeviceLabelTooLong = &errHTTP{40051, http.StatusBadRequest, "invalid request: device label too long", "", nil} // axon errHTTPBadRequestDevicePairingInvalid = &errHTTP{40052, http.StatusBadRequest, "invalid request: pairing code invalid, expired, or already used", "", nil} // axon diff --git a/server/server.go b/server/server.go index 4e7997bab..773b67537 100644 --- a/server/server.go +++ b/server/server.go @@ -76,6 +76,7 @@ type Server struct { priceCache *util.LookupCache[map[string]int64] // Stripe price ID -> price as cents (USD implied!) metricsHandler http.Handler // Handles /metrics if enable-metrics set, and listen-metrics-http not set ai *ai.Client // axon: AI layer; nil when disabled (see docs/ai-plan/) + aiConsentKey []byte // axon: private key for opaque provider identities embedder *ai.Embedder // axon: embeddings for semantic chat retrieval; nil when disabled mcpHandler http.Handler // axon: MCP endpoint for AI agents; nil when disabled closeChan chan bool @@ -127,6 +128,7 @@ var ( apiAIChatPath = "/v1/ai/chat" // axon apiAIBriefingPath = "/v1/ai/briefing" // axon apiAIChatStreamPath = "/v1/ai/chat/stream" // axon + apiAIConsentPath = "/v1/ai/consent" // axon: explicit account consent apiMCPPath = "/mcp" // axon: MCP endpoint apiUsersPath = "/v1/users" apiUsersAccessPath = "/v1/users/access" @@ -382,6 +384,10 @@ func New(conf *Config) (*Server, error) { s.closeDatabases() return nil, fmt.Errorf("protect existing digest topics: %w", err) } + if err := s.initAIConsent(); err != nil { + s.closeDatabases() + return nil, fmt.Errorf("initialize AI consent: %w", err) + } return s, nil } @@ -640,7 +646,7 @@ func (s *Server) handleError(w http.ResponseWriter, r *http.Request, v *visitor, ev.Info("Connection closed with HTTP %d (ntfy error %d)", httpErr.HTTPCode, httpErr.Code) } if isRateLimiting && s.config.StripeSecretKey != "" { - u := v.User() + u := requestUser(r, v) if u == nil || u.Tier == nil { httpErr = httpErr.Wrap("increase your limits with a paid plan, see %s", s.config.BaseURL) } @@ -663,7 +669,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit // management — tokens, passwords, reservations, billing, admin — and must // never be reachable with a device token (not even for admins, so an // admin's paired phone cannot mint unrestricted tokens). - if u := v.User(); u != nil && isDeviceScopedToken(u) && !deviceScopeAllowedPath(r.URL.Path) { + if u := requestUser(r, v); u != nil && isDeviceScopedToken(u) && !deviceScopeAllowedPath(r.URL.Path) { + return errHTTPForbidden + } + if !agentScopeAllowedRequest(requestUser(r, v), r) { return errHTTPForbidden } if r.Method == http.MethodGet && r.URL.Path == "/" && s.config.WebRoot == "/" { @@ -780,18 +789,20 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit return s.ensureAdmin(s.ensureAIEnabled(s.handleAIStatus))(w, r, v) // axon } else if r.Method == http.MethodGet && r.URL.Path == apiAIUsagePath { return s.ensureAIEnabled(s.limitRequests(s.handleAIUsage))(w, r, v) // axon: allowed anonymously, attribution by visitor + } else if r.URL.Path == apiAIConsentPath && (r.Method == http.MethodGet || r.Method == http.MethodPost || r.Method == http.MethodDelete) { + return s.ensureAIEnabled(s.ensureUser(s.ensureAIScope(s.limitRequests(s.handleAIConsent))))(w, r, v) } else if r.Method == http.MethodPost && r.URL.Path == apiAIPlanPath { - return s.ensureAIEnabled(s.ensureUser(s.ensureAIScope(s.limitRequests(s.handleAIPlan))))(w, r, v) // axon: plans cost LLM tokens — require a user (AX-5) + return s.ensureAIEnabled(s.ensureUser(s.ensureAIScope(s.limitRequests(s.ensureAIConsent(s.handleAIPlan)))))(w, r, v) // axon: plans cost LLM tokens — require a user (AX-5) } else if r.Method == http.MethodPost && r.URL.Path == apiAITunePath { - return s.ensureAIEnabled(s.ensureUser(s.ensureAIScope(s.limitRequests(s.handleAITune))))(w, r, v) // axon: tunes a synced subscription + return s.ensureAIEnabled(s.ensureUser(s.ensureAIScope(s.limitRequests(s.ensureAIConsent(s.handleAITune)))))(w, r, v) // axon: tunes a synced subscription } else if r.Method == http.MethodPost && r.URL.Path == apiAIDigestPath { - return s.ensureAIEnabled(s.ensureUser(s.ensureAIScope(s.limitRequests(s.handleAIDigest))))(w, r, v) // axon: digests a synced subscription + return s.ensureAIEnabled(s.ensureUser(s.ensureAIScope(s.limitRequests(s.ensureAIConsent(s.handleAIDigest)))))(w, r, v) // axon: digests a synced subscription } else if r.Method == http.MethodPost && r.URL.Path == apiAIChatPath { - return s.ensureAIEnabled(s.ensureUser(s.ensureAIScope(s.limitRequests(s.handleAIChat))))(w, r, v) // axon: asks about a synced subscription + return s.ensureAIEnabled(s.ensureUser(s.ensureAIScope(s.limitRequests(s.ensureAIConsent(s.handleAIChat)))))(w, r, v) // axon: asks about a synced subscription } else if r.Method == http.MethodPost && r.URL.Path == apiAIBriefingPath { - return s.ensureAIEnabled(s.ensureUser(s.ensureAIScope(s.limitRequests(s.handleAIBriefing))))(w, r, v) // axon: briefing across all subscriptions + return s.ensureAIEnabled(s.ensureUser(s.ensureAIScope(s.limitRequests(s.ensureAIConsent(s.handleAIBriefing)))))(w, r, v) // axon: briefing across all subscriptions } else if r.Method == http.MethodPost && r.URL.Path == apiAIChatStreamPath { - return s.ensureAIEnabled(s.ensureUser(s.ensureAIScope(s.limitRequests(s.handleAIChatStream))))(w, r, v) // axon: SSE variant of chat + return s.ensureAIEnabled(s.ensureUser(s.ensureAIScope(s.limitRequests(s.ensureAIConsent(s.handleAIChatStream)))))(w, r, v) // axon: SSE variant of chat } else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath { return s.handleMatrixDiscovery(w) } else if r.Method == http.MethodGet && r.URL.Path == metricsPath && s.metricsHandler != nil { @@ -1033,7 +1044,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess } if email != "" { var httpErr *errHTTP - email, httpErr = s.convertEmailAddress(v.User(), email) + email, httpErr = s.convertEmailAddress(requestUser(r, v), email) if httpErr != nil { return nil, httpErr.With(t) } else if !vrate.EmailAllowed() { @@ -1042,7 +1053,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess } if call != "" { var httpErr *errHTTP - call, httpErr = s.convertPhoneNumber(v.User(), call) + call, httpErr = s.convertPhoneNumber(requestUser(r, v), call) if httpErr != nil { return nil, httpErr.With(t) } else if !vrate.CallAllowed() { @@ -1053,7 +1064,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess m = model.NewPollRequestMessage(t.ID, m.PollID) } m.Sender = v.IP() - m.User = v.MaybeUserID() + m.User = requestUserID(r, v) if cache { m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() } @@ -1064,7 +1075,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess m.Message = emptyMessageBody } m.SanitizeUTF8() - s.maybeEnrichMessage(m) // axon: inline AI enrichment for opted-in topics (pass-through on breach) + s.maybeEnrichMessage(r.Context(), v, m) // axon: inline AI enrichment for opted-in topics (pass-through on breach) delayed := m.Time > time.Now().Unix() ev := logvrm(v, r, m). Tag(tagPublish). @@ -1115,7 +1126,7 @@ func (s *Server) handlePublishInternal(r *http.Request, v *visitor) (*model.Mess return nil, err } } - u := v.User() + u := requestUser(r, v) if s.userManager != nil && u != nil && u.Tier != nil { go s.userManager.EnqueueUserStats(u.ID, v.Stats()) } @@ -1191,7 +1202,7 @@ func (s *Server) handleActionMessage(w http.ResponseWriter, r *http.Request, v * // Create an action message with the given event type m := model.NewActionMessage(event, t.ID, sequenceID) m.Sender = v.IP() - m.User = v.MaybeUserID() + m.User = requestUserID(r, v) m.Expires = time.Unix(m.Time, 0).Add(v.Limits().MessageExpiryDuration).Unix() // Publish to subscribers, Firebase (for Android clients), and web push endpoints if err := s.dispatch(v, t, m, dispatchOpts{firebase: true, webPush: true}); err != nil { @@ -1644,7 +1655,7 @@ func (s *Server) handleSubscribeHTTP(w http.ResponseWriter, r *http.Request, v * defer cancel() subscriberIDs := make([]int, 0) for _, t := range topics { - subscriberIDs = append(subscriberIDs, t.Subscribe(sub, v.MaybeUserID(), cancel)) + subscriberIDs = append(subscriberIDs, t.Subscribe(sub, requestUserID(r, v), cancel)) } defer func() { for i, subscriberID := range subscriberIDs { @@ -1793,7 +1804,7 @@ func (s *Server) handleSubscribeWS(w http.ResponseWriter, r *http.Request, v *vi } subscriberIDs := make([]int, 0) for _, t := range topics { - subscriberIDs = append(subscriberIDs, t.Subscribe(sub, v.MaybeUserID(), cancel)) + subscriberIDs = append(subscriberIDs, t.Subscribe(sub, requestUserID(r, v), cancel)) } defer func() { for i, subscriberID := range subscriberIDs { @@ -1875,10 +1886,10 @@ func (s *Server) maybeSetRateVisitors(r *http.Request, v *visitor, topics []*top return err } if ownerUserID == "" { - if err := s.userManager.Authorize(v.User(), t.ID, user.PermissionWrite); err == nil { + if err := s.userManager.Authorize(requestUser(r, v), t.ID, user.PermissionWrite); err == nil { writableRateTopics = append(writableRateTopics, t) } - } else if ownerUserID == v.MaybeUserID() { + } else if ownerUserID == requestUserID(r, v) { writableRateTopics = append(writableRateTopics, t) } } diff --git a/server/server_account.go b/server/server_account.go index a13522392..2d8ac1d07 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -23,7 +23,7 @@ const ( ) func (s *Server) handleAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) if !u.IsAdmin() { // u may be nil, but that's fine if !s.config.EnableSignup { return errHTTPBadRequestSignupNotEnabled @@ -70,10 +70,9 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis if err != nil { return err } - // axon: a device-scoped token (pairing flow) must never see the account's - // token values — that would let a paired phone escalate to unrestricted - // credentials. The app only needs prefs/limits here. - stripTokens := isDeviceScopedToken(v.User()) + // Scoped credentials must never see token values that can upgrade their scopes. + u := requestUser(r, v) + stripTokens := u != nil && len(u.TokenScopes) > 0 logvr(v, r).Tag(tagAccount).Fields(visitorExtendedInfoContext(info)).Debug("Retrieving account stats") limits, stats := info.Limits, info.Stats response := &apiAccountResponse{ @@ -102,7 +101,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, r *http.Request, v *vis AttachmentTotalSizeRemaining: stats.AttachmentTotalSizeRemaining, }, } - u := v.User() if u != nil { response.Username = u.Name response.Role = string(u.Role) @@ -224,7 +222,7 @@ func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request, v * } else if req.Password == "" { return errHTTPBadRequest } - u := v.User() + u := requestUser(r, v) if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil { return errHTTPBadRequestIncorrectPasswordConfirmation } @@ -262,7 +260,7 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ } else if req.Password == "" || req.NewPassword == "" { return errHTTPBadRequest } - u := v.User() + u := requestUser(r, v) if _, err := s.userManager.Authenticate(u.Name, req.Password); err != nil { return errHTTPBadRequestIncorrectPasswordConfirmation } @@ -281,7 +279,7 @@ func (s *Server) handleAccountPasswordChange(w http.ResponseWriter, r *http.Requ // the token endpoint (which exists to mint arbitrary API tokens), this endpoint's job is to log a // user in, so it also reports who they are (the identifier they typed may be a primary email). func (s *Server) handleAccountLogin(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) logvr(v, r).Tag(tagAccount).Info("Logging in user %s", u.Name) token, err := s.userManager.CreateToken(u.ID, "", time.Now().Add(tokenExpiryDuration), v.IP(), false, "") if err != nil { @@ -307,7 +305,7 @@ func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request if req.Expires != nil { expires = time.Unix(*req.Expires, 0) } - u := v.User() + u := requestUser(r, v) logvr(v, r). Tag(tagAccount). Fields(log.Context{ @@ -330,7 +328,10 @@ func (s *Server) handleAccountTokenCreate(w http.ResponseWriter, r *http.Request } func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) + if u == nil { + return errHTTPUnauthorized + } req, err := readJSONWithLimit[apiAccountTokenUpdateRequest](r.Body, jsonBodyBytesLimit, true) // Allow empty body! if err != nil { return err @@ -371,7 +372,7 @@ func (s *Server) handleAccountTokenUpdate(w http.ResponseWriter, r *http.Request } func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) token := readParam(r, "X-Token", "Token") // DELETEs cannot have a body, and we don't want it in the path if token == "" { token = u.Token @@ -397,7 +398,7 @@ func (s *Server) handleAccountSettingsChange(w http.ResponseWriter, r *http.Requ if err != nil { return err } - u := v.User() + u := requestUser(r, v) if u.Prefs == nil { u.Prefs = &user.Prefs{} } @@ -475,7 +476,7 @@ func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Req if err != nil { return err } - u := v.User() + u := requestUser(r, v) prefs := u.Prefs if prefs == nil { prefs = &user.Prefs{} @@ -498,7 +499,7 @@ func (s *Server) handleAccountSubscriptionChange(w http.ResponseWriter, r *http. if err != nil { return err } - u := v.User() + u := requestUser(r, v) prefs := u.Prefs if prefs == nil || prefs.Subscriptions == nil { return errHTTPNotFound @@ -525,7 +526,7 @@ func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http. // DELETEs cannot have a body, and we don't want it in the path deleteBaseURL := readParam(r, "X-BaseURL", "BaseURL") deleteTopic := readParam(r, "X-Topic", "Topic") - u := v.User() + u := requestUser(r, v) prefs := u.Prefs if prefs == nil || prefs.Subscriptions == nil { return nil @@ -551,7 +552,7 @@ func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http. // with enough remaining reservations left, or if the user is an admin. Admins can always reserve a topic, unless // it is already reserved by someone else. func (s *Server) handleAccountReservationAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) req, err := readJSONWithLimit[apiAccountReservationRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err @@ -606,7 +607,7 @@ func (s *Server) handleAccountReservationDelete(w http.ResponseWriter, r *http.R if !topicRegex.MatchString(topic) { return errHTTPBadRequestTopicInvalid } - u := v.User() + u := requestUser(r, v) authorized, err := s.userManager.HasReservation(u.Name, topic) if err != nil { return err @@ -653,7 +654,7 @@ func (s *Server) maybeRemoveMessagesAndExcessReservations(r *http.Request, v *vi } func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) req, err := readJSONWithLimit[apiAccountPhoneNumberVerifyRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err @@ -684,7 +685,7 @@ func (s *Server) handleAccountPhoneNumberVerify(w http.ResponseWriter, r *http.R } func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err @@ -706,7 +707,7 @@ func (s *Server) handleAccountPhoneNumberAdd(w http.ResponseWriter, r *http.Requ } func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) req, err := readJSONWithLimit[apiAccountPhoneNumberAddRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err @@ -725,7 +726,7 @@ func (s *Server) handleAccountPhoneNumberDelete(w http.ResponseWriter, r *http.R // magic-link token, stores a pending verification, and emails the link. The address is NOT // added to the verified list until the user clicks the link (handleAccountEmailVerify). func (s *Server) handleAccountEmailAdd(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err @@ -774,8 +775,8 @@ func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request return err } logvr(v, r).Tag(tagAccount).Field("email", m.Email).Info("Email verified") - // Refresh the verified user's other sessions. The request is unauthenticated (v.User() is - // usually nil), so resolve the user from the token row and publish to their sync topic. + // Refresh the verified user's other sessions. The request is usually unauthenticated, + // so resolve the user from the token row and publish to their sync topic. s.publishSyncEventForUserIDAsync(v, m.UserID) return s.writeJSON(w, newSuccessResponse()) } @@ -783,7 +784,7 @@ func (s *Server) handleAccountEmailVerify(w http.ResponseWriter, r *http.Request // handleAccountEmailDelete removes an email address, whether verified or still pending // (DELETE /v1/account/email). Removing the primary leaves the account with no primary. func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err @@ -804,7 +805,7 @@ func (s *Server) handleAccountEmailDelete(w http.ResponseWriter, r *http.Request // handleAccountEmailSetPrimary marks an already-verified email as the user's primary (recovery) // email (POST /v1/account/email/primary). func (s *Server) handleAccountEmailSetPrimary(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err @@ -825,7 +826,7 @@ func (s *Server) handleAccountEmailSetPrimary(w http.ResponseWriter, r *http.Req // handleAccountEmailResend re-sends a pending email verification (POST /v1/account/email/resend). func (s *Server) handleAccountEmailResend(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) req, err := readJSONWithLimit[apiAccountEmailRequest](r.Body, jsonBodyBytesLimit, false) if err != nil { return err @@ -985,19 +986,18 @@ func (s *Server) convertEmailAddress(u *user.User, email string) (string, *errHT } // publishSyncEventAsync kicks of a Go routine to publish a sync message to the user's sync topic -func (s *Server) publishSyncEventAsync(v *visitor) { +func (s *Server) publishSyncEventAsync(v *visitor, principals ...*user.User) { + u := v.User() + if len(principals) > 0 { + u = principals[0] + } go func() { - if err := s.publishSyncEvent(v); err != nil { + if err := s.publishSyncEventForUser(v, u); err != nil { logv(v).Err(err).Trace("Error publishing to user's sync topic") } }() } -// publishSyncEvent publishes a sync message to the authenticated user's sync topic -func (s *Server) publishSyncEvent(v *visitor) error { - return s.publishSyncEventForUser(v, v.User()) -} - // publishSyncEventForUserIDAsync publishes a sync event to the sync topic of the user with the // given ID, resolving the user first. Used by the unauthenticated email-verify handler, where // the request visitor has no associated user but the token identifies the account to refresh. diff --git a/server/server_ai.go b/server/server_ai.go index 4f6aebeb9..d0dcadc28 100644 --- a/server/server_ai.go +++ b/server/server_ai.go @@ -47,7 +47,7 @@ func (s *Server) ensureAIEnabled(next handleFunc) handleFunc { // (plan, tune, digest, chat), not to status/usage. func (s *Server) ensureAIScope(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if u := v.User(); u != nil && !u.HasTokenScope(user.TokenScopeAI) { + if u := requestUser(r, v); u != nil && !u.HasTokenScope(user.TokenScopeAI) { log.Tag(tagAI).With(v).Warn("Token does not carry the ai scope, rejecting request") return errHTTPForbidden } @@ -88,7 +88,7 @@ func (s *Server) handleAIStatus(w http.ResponseWriter, r *http.Request, v *visit // It is intentionally available to anonymous visitors: AI features are rate-limited per // visitor, so unauthenticated clients can have usage too. func (s *Server) handleAIUsage(w http.ResponseWriter, r *http.Request, v *visitor) error { - return s.writeJSON(w, s.ai.Report(visitorID(v.ip, v.user, v.config))) + return s.writeJSON(w, s.ai.Report(aiVisitorID(r.Context(), v))) } const ( @@ -103,7 +103,7 @@ const ( // passes through unchanged (instant delivery is ntfy's core promise), and // - the enriched message is what gets cached, so every subscriber and poller sees // the same enrichment and the provider is paid once per message. -func (s *Server) maybeEnrichMessage(m *model.Message) { +func (s *Server) maybeEnrichMessage(parent context.Context, v *visitor, m *model.Message) { if !s.ai.Enabled() || !s.config.AIEnrichmentEnabled { return } @@ -117,9 +117,18 @@ func (s *Server) maybeEnrichMessage(m *model.Message) { if slices.Contains(s.config.AITranslateTopics, m.Topic) { translateTo = s.config.AITranslateLang } - ctx, cancel := context.WithTimeout(context.Background(), s.config.AIInlineTimeout) + ctx, cancel := context.WithTimeout(parent, s.config.AIInlineTimeout) defer cancel() - enrichment, err := ai.NewEnricher(s.ai).Enrich(ctx, &ai.EnrichRequest{Topic: m.Topic, Title: m.Title, Message: m.Message, TranslateTo: translateTo}) + u := contextUser(parent, v) + if u == nil || !u.HasTokenScope(user.TokenScopeAI) { + return + } + allowed, err := s.hasAIConsent(ctx, u) + if err != nil || !allowed { + metrics.AIEnrichmentPassThrough.Inc() + return // Delivery succeeds unchanged; no provider call without consent. + } + enrichment, err := ai.NewEnricher(s.ai).Enrich(ctx, &ai.EnrichRequest{UserKey: aiVisitorID(parent, v), Topic: m.Topic, Title: m.Title, Message: m.Message, TranslateTo: translateTo}) if err != nil { log.Tag(tagAI).Err(err).Field("topic", m.Topic).Field("message_id", m.ID).Debug("AI enrichment skipped, passing through") metrics.AIEnrichmentPassThrough.Inc() @@ -170,9 +179,9 @@ func (s *Server) handleAIPlan(w http.ResponseWriter, r *http.Request, v *visitor if !v.aiQuotaAllowed() { return errHTTPTooManyRequestsLimitAIRequests } - existingTopics := visitorTopics(v) + existingTopics := visitorTopics(requestUser(r, v)) planner := ai.NewPlanner(s.ai) - plan, err := planner.Plan(r.Context(), visitorID(v.ip, v.user, v.config), s.config.BaseURL, body.Prompt, body.Locale, existingTopics, sanitizePlanContext(body.Context)) + plan, err := planner.Plan(r.Context(), aiVisitorID(r.Context(), v), s.config.BaseURL, body.Prompt, body.Locale, existingTopics, sanitizePlanContext(body.Context)) if err != nil { return s.mapAIError(err) } @@ -207,7 +216,7 @@ func (s *Server) handleAITune(w http.ResponseWriter, r *http.Request, v *visitor return errHTTPTooManyRequestsLimitAIRequests } planner := ai.NewPlanner(s.ai) - tune, err := planner.Tune(r.Context(), visitorID(v.ip, v.user, v.config), &ai.TuneInput{ + tune, err := planner.Tune(r.Context(), aiVisitorID(r.Context(), v), &ai.TuneInput{ Topic: body.Topic, DisplayName: body.DisplayName, Search: body.Search, @@ -274,7 +283,7 @@ func (s *Server) handleAIDigest(w http.ResponseWriter, r *http.Request, v *visit if !topicRegex.MatchString(body.Topic) { return errHTTPBadRequestAIRequest } - u := v.User() + u := requestUser(r, v) if !userSubscribesTo(u, s.config.BaseURL, body.Topic) || !s.canReadTopic(u, body.Topic) { return errHTTPForbidden // Only your own subscriptions, keeps the quota honest } @@ -317,7 +326,7 @@ func (s *Server) handleAIDigest(w http.ResponseWriter, r *http.Request, v *visit if s.embedder != nil { digester = ai.NewDigesterWithEmbedder(s.ai, s.embedder) // axon: semantic pre-clustering } - digest, err := digester.Digest(r.Context(), visitorID(v.ip, v.user, v.config), &ai.DigestInput{ + digest, err := digester.Digest(r.Context(), aiVisitorID(r.Context(), v), &ai.DigestInput{ Topic: body.Topic, Messages: digestMessages, }) if err != nil { @@ -358,14 +367,14 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request, v *visitor if err != nil { return err } - question, context, messageTopics, err := s.chatContext(v, body) + question, context, messageTopics, err := s.chatContext(r, v, body) if err != nil { return err } if len(context) == 0 { return s.writeJSON(w, s.emptyChatResponse(body, question)) } - context = selectChatContext(r.Context(), question, context, s.embedder) + context = selectChatContext(r.Context(), question, context, s.embedder, aiVisitorID(r.Context(), v)) log.Tag(tagAI).With(v).Fields(log.Context{ "ai_feature": string(ai.FeatureChat), "ai_chat_topic": body.Topic, @@ -376,7 +385,7 @@ func (s *Server) handleAIChat(w http.ResponseWriter, r *http.Request, v *visitor for _, m := range context { chatMessages = append(chatMessages, ai.DigestMessage{ID: m.ID, Topic: messageTopics[m.ID], Title: m.Title, Message: m.Message, Priority: m.Priority, Time: m.Time}) } - answer, err := ai.NewChatter(s.ai).Chat(r.Context(), visitorID(v.ip, v.user, v.config), body.Topic, question, chatMessages, sanitizeChatHistory(body.History)) + answer, err := ai.NewChatter(s.ai).Chat(r.Context(), aiVisitorID(r.Context(), v), body.Topic, question, chatMessages, sanitizeChatHistory(body.History)) if err != nil { return s.mapAIError(err) } @@ -407,7 +416,7 @@ func (s *Server) handleAIChatStream(w http.ResponseWriter, r *http.Request, v *v if err != nil { return err } - question, context, messageTopics, err := s.chatContext(v, body) + question, context, messageTopics, err := s.chatContext(r, v, body) if err != nil { return err } @@ -433,7 +442,7 @@ func (s *Server) handleAIChatStream(w http.ResponseWriter, r *http.Request, v *v _ = writeEvent(map[string]any{"type": "delta", "text": "There are no messages in this topic for the selected period."}) return writeEvent(map[string]any{"type": "done"}) } - context = selectChatContext(ctx, question, context, s.embedder) + context = selectChatContext(ctx, question, context, s.embedder, aiVisitorID(r.Context(), v)) chatMessages := make([]ai.DigestMessage, 0, len(context)) for _, m := range context { chatMessages = append(chatMessages, ai.DigestMessage{ID: m.ID, Topic: messageTopics[m.ID], Title: m.Title, Message: m.Message, Priority: m.Priority, Time: m.Time}) @@ -452,13 +461,13 @@ func (s *Server) handleAIChatStream(w http.ResponseWriter, r *http.Request, v *v } return writeEvent(map[string]any{"type": "citations", "text": answerText, "citations": citations}) } - events, err := ai.NewChatter(s.ai).ChatStream(ctx, visitorID(v.ip, v.user, v.config), body.Topic, question, chatMessages, sanitizeChatHistory(body.History)) + events, err := ai.NewChatter(s.ai).ChatStream(ctx, aiVisitorID(r.Context(), v), body.Topic, question, chatMessages, sanitizeChatHistory(body.History)) if err != nil { if !errors.Is(err, ai.ErrStreamingNotSupported) { return s.mapAIError(err) } // Graceful degradation: provider cannot stream, answer in one shot - answer, err := ai.NewChatter(s.ai).Chat(ctx, visitorID(v.ip, v.user, v.config), body.Topic, question, chatMessages, sanitizeChatHistory(body.History)) + answer, err := ai.NewChatter(s.ai).Chat(ctx, aiVisitorID(r.Context(), v), body.Topic, question, chatMessages, sanitizeChatHistory(body.History)) if err != nil { return s.mapAIError(err) } @@ -505,7 +514,7 @@ func (s *Server) handleAIChatStream(w http.ResponseWriter, r *http.Request, v *v // enforced by the route decorators; this enforces the request shape and the // subscription rule, and gathers the context window (single topic or cross-topic). // A nil context return means "no messages in the window" (not an error). -func (s *Server) chatContext(v *visitor, body *apiAIChatRequest) (string, []*model.Message, map[string]string, error) { +func (s *Server) chatContext(r *http.Request, v *visitor, body *apiAIChatRequest) (string, []*model.Message, map[string]string, error) { question := strings.TrimSpace(body.Question) if question == "" || len(question) > 1000 || (body.All && body.Topic != "") { return "", nil, nil, errHTTPBadRequestAIRequest @@ -513,7 +522,7 @@ func (s *Server) chatContext(v *visitor, body *apiAIChatRequest) (string, []*mod if !body.All && !topicRegex.MatchString(body.Topic) { return "", nil, nil, errHTTPBadRequestAIRequest } - u := v.User() + u := requestUser(r, v) if !body.All && (!userSubscribesTo(u, s.config.BaseURL, body.Topic) || !s.canReadTopic(u, body.Topic)) { return "", nil, nil, errHTTPForbidden } @@ -587,7 +596,7 @@ func toDigestMessages(messages []*model.Message) []ai.DigestMessage { // embedder, retrieval is hybrid: 0.6 * cosine(question, message) + 0.4 * normalized // term overlap. Without one, it falls back to term overlap and recency only. // Deterministic and cheap; embeddings are batched and cached per text. -func selectChatContext(ctx context.Context, question string, messages []*model.Message, embedder *ai.Embedder) []*model.Message { +func selectChatContext(ctx context.Context, question string, messages []*model.Message, embedder *ai.Embedder, userKeys ...string) []*model.Message { const k = 60 tokens := questionTokens(question) scores := make(map[string]float64, len(messages)) // id -> score @@ -620,12 +629,12 @@ func selectChatContext(ctx context.Context, question string, messages []*model.M sort.SliceStable(messages, func(i, j int) bool { return messages[i].Time < messages[j].Time }) return messages } - return hybridReorder(ctx, question, messages, scores, embedder) + return hybridReorder(ctx, question, messages, scores, embedder, userKeys...) } // hybridReorder re-ranks the top candidates by blending cosine(question, message) with // the term-overlap score. Embedding failures degrade silently to the term order. -func hybridReorder(ctx context.Context, question string, messages []*model.Message, scores map[string]float64, embedder *ai.Embedder) []*model.Message { +func hybridReorder(ctx context.Context, question string, messages []*model.Message, scores map[string]float64, embedder *ai.Embedder, userKeys ...string) []*model.Message { texts := make([]string, 0, len(messages)+1) maxTerm := 0.0 for _, m := range messages { @@ -635,7 +644,7 @@ func hybridReorder(ctx context.Context, question string, messages []*model.Messa } } texts = append(texts, question) - vectors, err := embedder.Embed(ctx, texts) + vectors, err := embedder.Embed(ctx, texts, userKeys...) if err != nil { log.Tag(tagAI).Err(err).Debug("Embedding failed, keeping keyword order") return messages @@ -701,7 +710,7 @@ func (s *Server) handleAIBriefing(w http.ResponseWriter, r *http.Request, v *vis sinceDuration = parsed } sinceMarker := model.NewSinceTime(time.Now().Add(-1 * sinceDuration).Unix()) - u := v.User() + u := requestUser(r, v) totalMessages := 0 topics := make([]ai.BriefingTopicMessages, 0) if u.Prefs != nil { @@ -738,7 +747,7 @@ func (s *Server) handleAIBriefing(w http.ResponseWriter, r *http.Request, v *vis Disclaimer: ai.BriefingDisclaimer, }) } - briefing, err := ai.NewBriefinger(s.ai).Briefing(r.Context(), visitorID(v.ip, v.user, v.config), &ai.BriefingInput{Topics: topics}) + briefing, err := ai.NewBriefinger(s.ai).Briefing(r.Context(), aiVisitorID(r.Context(), v), &ai.BriefingInput{Topics: topics}) if err != nil { return s.mapAIError(err) } @@ -789,8 +798,7 @@ func userSubscribesTo(u *user.User, baseURL, topic string) bool { // visitorTopics returns the topics of the visitor's synced subscriptions (empty for // anonymous visitors), used as context for the planner. -func visitorTopics(v *visitor) []string { - u := v.User() +func visitorTopics(u *user.User) []string { if u == nil || u.Prefs == nil { return nil } @@ -802,3 +810,12 @@ func visitorTopics(v *visitor) []string { } return topics } + +// AI accounting is account-based even for users without a paid tier. Anonymous +// publishers retain the normal IP-prefix attribution. This matches the scheduler. +func aiVisitorID(ctx context.Context, v *visitor) string { + if u := contextUser(ctx, v); u != nil { + return "user:" + u.ID + } + return visitorID(v.ip, nil, v.config) +} diff --git a/server/server_ai_accounting_test.go b/server/server_ai_accounting_test.go new file mode 100644 index 000000000..2722bdab9 --- /dev/null +++ b/server/server_ai_accounting_test.go @@ -0,0 +1,34 @@ +package server + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/ai" + "heckel.io/ntfy/v2/user" + "heckel.io/ntfy/v2/util" +) + +func TestServer_AI_Enrichment_AttributesPublisherUsage(t *testing.T) { + c := newTestConfigWithAuthFile(t, "") + c.AIEnabled = true + c.AIProvider = "mock" + c.AIEnrichmentEnabled = true + c.AIEnrichTopics = []string{"alerts"} + s := newTestServer(t, c) + defer s.closeDatabases() + require.NoError(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false)) + grantTestAIConsent(t, s, "alice") + auth := map[string]string{"Authorization": util.BasicAuth("alice", "alice")} + s.ai.Mock().Enqueue(ai.MockResult{Response: &ai.Response{Text: `{"summary":"Alert"}`, InputTokens: 3, OutputTokens: 2}}) + rr := request(t, s, "PUT", "/alerts", strings.Repeat("message data ", 30), auth) + require.Equal(t, 200, rr.Code) + rr = request(t, s, "GET", "/v1/ai/usage", "", auth) + require.Equal(t, 200, rr.Code) + var usage ai.UsageReport + require.NoError(t, json.NewDecoder(rr.Body).Decode(&usage)) + require.Equal(t, int64(1), usage.Visitor.Requests) + require.Equal(t, int64(5), usage.Visitor.Total()) +} diff --git a/server/server_ai_briefing_test.go b/server/server_ai_briefing_test.go index fee8c8732..f02ec8a85 100644 --- a/server/server_ai_briefing_test.go +++ b/server/server_ai_briefing_test.go @@ -17,6 +17,7 @@ func TestServer_AI_Briefing(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} // Subscribe to two topics, seed both @@ -75,6 +76,7 @@ func TestServer_AI_Briefing_EmptySkipsProvider(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} request(t, s, "POST", "/v1/account/subscription", `{"base_url":"`+s.config.BaseURL+`","topic":"quiet"}`, auth) diff --git a/server/server_ai_chat_test.go b/server/server_ai_chat_test.go index cf86829c7..a34a7f31b 100644 --- a/server/server_ai_chat_test.go +++ b/server/server_ai_chat_test.go @@ -18,6 +18,7 @@ func TestServer_AI_Chat(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} // Gates: anonymous and unsubscribed @@ -66,6 +67,7 @@ func TestServer_AI_Chat_EmptyWindow(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} request(t, s, "POST", "/v1/account/subscription", `{"base_url":"`+s.config.BaseURL+`","topic":"quiet-topic"}`, auth) @@ -85,6 +87,7 @@ func TestServer_AI_Chat_InvalidQuestion(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} request(t, s, "POST", "/v1/account/subscription", `{"base_url":"`+s.config.BaseURL+`","topic":"backups"}`, auth) @@ -101,6 +104,7 @@ func TestServer_AI_Chat_AllTopics(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} for _, topic := range []string{"backups", "home-alerts"} { rr := request(t, s, "POST", "/v1/account/subscription", `{"base_url":"`+s.config.BaseURL+`","topic":"`+topic+`"}`, auth) @@ -142,6 +146,7 @@ func TestServer_AI_ChatStream(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} request(t, s, "POST", "/v1/account/subscription", `{"base_url":"`+s.config.BaseURL+`","topic":"backups"}`, auth) request(t, s, "PUT", "/backups", "backup failed exit 2", map[string]string{"Title": "failure"}) @@ -180,6 +185,7 @@ func TestServer_AI_ChatStream_Gates(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} // Anonymous: 401; unsubscribed: 403 @@ -204,6 +210,7 @@ func TestServer_AI_Chat_HybridRetrieval(t *testing.T) { defer s.closeDatabases() require.NotNil(t, s.embedder) // wired when ai-embeddings-model is set require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} request(t, s, "POST", "/v1/account/subscription", `{"base_url":"`+s.config.BaseURL+`","topic":"ops"}`, auth) request(t, s, "PUT", "/ops", "the database migration failed at 03:00", map[string]string{"Title": "migration failure"}) @@ -242,7 +249,9 @@ func TestServer_AI_ACLs_EnforcedAtReadTime(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) // owns the topic + grantTestAIConsent(t, s, "phil") require.Nil(t, s.userManager.AddUser("maria", "maria", user.RoleUser, false)) + grantTestAIConsent(t, s, "maria") phil := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} maria := map[string]string{"Authorization": util.BasicAuth("maria", "maria")} diff --git a/server/server_ai_consent.go b/server/server_ai_consent.go new file mode 100644 index 000000000..58549595e --- /dev/null +++ b/server/server_ai_consent.go @@ -0,0 +1,158 @@ +package server + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "mime" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "heckel.io/ntfy/v2/user" +) + +const aiConsentLookupTimeout = 2 * time.Second + +type apiAIConsentResponse struct { + Consented bool `json:"consented"` + Provider string `json:"provider"` + Endpoint string `json:"endpoint,omitempty"` + Models []string `json:"models"` + Configuration string `json:"configuration"` + CanManage bool `json:"can_manage"` + GrantedAt int64 `json:"granted_at,omitempty"` +} + +type apiAIConsentRequest struct { + Accepted bool `json:"accepted"` + Configuration string `json:"configuration"` +} + +func (s *Server) initAIConsent() error { + if !s.ai.Enabled() || s.userManager == nil { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), aiConsentLookupTimeout) + defer cancel() + key, err := s.userManager.AIConsentSecret(ctx) + if err != nil { + return err + } + s.aiConsentKey = key + return nil +} + +func (s *Server) aiConsentModels() []string { + models := []string{s.ai.DefaultModel(), s.config.AIModelPlan, s.config.AIModelEnrich, s.config.AIModelDigest, s.config.AIModelChat, s.config.AIEmbeddingsModel} + models = slices.DeleteFunc(models, func(model string) bool { return model == "" }) + slices.Sort(models) + return slices.Compact(models) +} + +func (s *Server) aiConsentConfiguration() string { + identity, _ := json.Marshal(struct { + Provider string + Endpoint string + Models []string + }{s.ai.ProviderName(), s.ai.ProviderBaseURL(), []string{s.ai.DefaultModel(), s.config.AIModelPlan, s.config.AIModelEnrich, s.config.AIModelDigest, s.config.AIModelChat, s.config.AIEmbeddingsModel}}) + mac := hmac.New(sha256.New, s.aiConsentKey) + _, _ = mac.Write(identity) + return fmt.Sprintf("%x", mac.Sum(nil)) +} + +// aiProviderOrigin deliberately omits credentials, path, query and fragment. +func aiProviderOrigin(endpoint string) string { + u, err := url.Parse(endpoint) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { + return "" + } + return u.Scheme + "://" + u.Host +} + +func (s *Server) hasAIConsent(ctx context.Context, u *user.User) (bool, error) { + if u == nil || s.userManager == nil { + return false, nil + } + if len(s.aiConsentKey) != 32 { + return false, errors.New("AI consent metadata unavailable") + } + lookupCtx, cancel := context.WithTimeout(ctx, aiConsentLookupTimeout) + defer cancel() + consent, err := s.userManager.AIConsent(lookupCtx, u.ID) + return err == nil && consent != nil && consent.ProviderConfig == s.aiConsentConfiguration(), err +} + +func (s *Server) ensureAIConsent(next handleFunc) handleFunc { + return func(w http.ResponseWriter, r *http.Request, v *visitor) error { + allowed, err := s.hasAIConsent(r.Context(), requestUser(r, v)) + if err != nil { + return err // Fail closed on lookup errors, including cancellation. + } else if !allowed { + return errHTTPForbiddenAIConsentRequired + } + return next(w, r, v) + } +} + +func (s *Server) aiConsentOriginAllowed(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" { + return true // Authenticated API clients need not be browser clients. + } + expected := aiProviderOrigin(s.config.BaseURL) + if origin == expected || (s.config.AccessControlAllowOrigin != "*" && origin == s.config.AccessControlAllowOrigin) { + return true + } + parsed, err := url.Parse(origin) + return expected == "" && err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host == r.Host +} + +func (s *Server) handleAIConsent(w http.ResponseWriter, r *http.Request, v *visitor) error { + u := requestUser(r, v) + ctx, cancel := context.WithTimeout(r.Context(), aiConsentLookupTimeout) + defer cancel() + if r.Method != http.MethodGet { + // Grant/revoke is an owner action. An AI-scoped agent cannot consent for them. + if len(u.TokenScopes) != 0 || !s.aiConsentOriginAllowed(r) { + return errHTTPForbidden + } + if r.Method == http.MethodPost { + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || !strings.EqualFold(mediaType, "application/json") { + return errHTTPBadRequestAIRequest // Reject form/text/plain consent CSRF. + } + body, err := readJSONWithLimit[apiAIConsentRequest](r.Body, jsonBodyBytesLimit, false) + if err != nil { + return err + } else if !body.Accepted { + return errHTTPBadRequestAIRequest + } else if body.Configuration != s.aiConsentConfiguration() { + return errHTTPConflictAIProviderChanged + } + if err := s.userManager.GrantAIConsent(ctx, u.ID, body.Configuration); err != nil { + return err + } + } else if err := s.userManager.RevokeAIConsent(ctx, u.ID); err != nil { + return err + } + } + consent, err := s.userManager.AIConsent(ctx, u.ID) + if err != nil { + return err + } + response := &apiAIConsentResponse{ + Provider: s.ai.ProviderName(), Endpoint: aiProviderOrigin(s.ai.ProviderBaseURL()), + Models: s.aiConsentModels(), Configuration: s.aiConsentConfiguration(), CanManage: len(u.TokenScopes) == 0, + } + if consent != nil && consent.ProviderConfig == response.Configuration { + response.Consented = true + response.GrantedAt = consent.GrantedAt.Unix() + } + return s.writeJSON(w, response) +} diff --git a/server/server_ai_consent_repro_test.go b/server/server_ai_consent_repro_test.go new file mode 100644 index 000000000..42ca979c1 --- /dev/null +++ b/server/server_ai_consent_repro_test.go @@ -0,0 +1,45 @@ +package server + +import ( + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/ai" + "heckel.io/ntfy/v2/user" + "heckel.io/ntfy/v2/util" +) + +// Consent must be checked before parsing content, querying history or consulting a cache. +func TestServer_AIConsent_DefaultDeniedEveryContentEndpoint(t *testing.T) { + c := newTestConfigWithAuthFile(t, "") + c.AIEnabled, c.AIProvider = true, "mock" + s := newTestServer(t, c) + defer s.closeDatabases() + require.NoError(t, s.userManager.AddUser("alice", "alice", user.RoleAdmin, false)) + auth := map[string]string{"Authorization": util.BasicAuth("alice", "alice")} + rr := request(t, s, "POST", "/v1/account/subscription", `{"base_url":"`+c.BaseURL+`","topic":"alerts"}`, auth) + require.Equal(t, 200, rr.Code) + rr = request(t, s, "PUT", "/alerts", "private account content", auth) + require.Equal(t, 200, rr.Code) + var calls atomic.Int32 + var embedCalls atomic.Int32 + s.embedder = s.ai.Embedder("test-embeddings") + s.ai.Mock().SetEmbedHandler(func(text string) ([]float32, error) { + embedCalls.Add(1) + return []float32{1, 0}, nil + }) + s.ai.Mock().SetHandler(func(req *ai.Request) (*ai.Response, error) { + calls.Add(1) + return &ai.Response{Text: `{"subscriptions":[{"topic":"alerts"}],"display_name":"Alerts","summary":"Private","answer":"Private","headline":"Private","sections":[]}`}, nil + }) + for _, endpoint := range []string{"plan", "tune", "digest", "chat", "briefing", "chat/stream"} { + t.Run(endpoint, func(t *testing.T) { + rr := request(t, s, "POST", "/v1/ai/"+endpoint, `{"prompt":"private wish","topic":"alerts","goal":"quiet","question":"private question"}`, auth) + require.Equal(t, 403, rr.Code, rr.Body.String()) + require.Equal(t, 40302, toHTTPError(t, rr.Body.String()).Code) + }) + } + require.Zero(t, calls.Load()) + require.Zero(t, embedCalls.Load()) +} diff --git a/server/server_ai_consent_test.go b/server/server_ai_consent_test.go new file mode 100644 index 000000000..ba3ba0faf --- /dev/null +++ b/server/server_ai_consent_test.go @@ -0,0 +1,276 @@ +package server + +import ( + "context" + "encoding/json" + "fmt" + "net/netip" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/ai" + "heckel.io/ntfy/v2/user" + "heckel.io/ntfy/v2/util" +) + +func grantTestAIConsent(t *testing.T, s *Server, username string) { + t.Helper() + u, err := s.userManager.User(username) + require.NoError(t, err) + require.NoError(t, s.userManager.GrantAIConsent(context.Background(), u.ID, s.aiConsentConfiguration())) +} + +func consentAuth(username string) map[string]string { + return map[string]string{"Authorization": util.BasicAuth(username, username), "Content-Type": "application/json"} +} + +func newConsentTestServer(t *testing.T) *Server { + c := newTestConfigWithAuthFile(t, "") + c.AIEnabled, c.AIProvider = true, "mock" + s := newTestServer(t, c) + t.Cleanup(func() { s.closeDatabases() }) + for _, name := range []string{"alice", "bob"} { + require.NoError(t, s.userManager.AddUser(name, name, user.RoleAdmin, false)) + } + return s +} + +func consentStatus(t *testing.T, s *Server, auth map[string]string) apiAIConsentResponse { + t.Helper() + rr := request(t, s, "GET", apiAIConsentPath, "", auth) + require.Equal(t, 200, rr.Code, rr.Body.String()) + var status apiAIConsentResponse + require.NoError(t, json.NewDecoder(rr.Body).Decode(&status)) + return status +} + +func TestServer_AIConsent_OwnerGrantRevokeAndCachedResult(t *testing.T) { + s := newConsentTestServer(t) + auth := consentAuth("alice") + status := consentStatus(t, s, auth) + require.False(t, status.Consented) + require.True(t, status.CanManage) + require.Len(t, status.Configuration, 64) + var calls atomic.Int32 + s.ai.Mock().SetHandler(func(req *ai.Request) (*ai.Response, error) { + calls.Add(1) + return &ai.Response{Text: `{"subscriptions":[{"topic":"alerts"}]}`}, nil + }) + rr := request(t, s, "POST", apiAIConsentPath, `{"accepted":true,"configuration":"`+status.Configuration+`"}`, auth) + require.Equal(t, 200, rr.Code, rr.Body.String()) + status = consentStatus(t, s, auth) + require.True(t, status.Consented) + require.Positive(t, status.GrantedAt) + require.False(t, consentStatus(t, s, consentAuth("bob")).Consented) + for i := 0; i < 2; i++ { + rr = request(t, s, "POST", "/v1/ai/plan", `{"prompt":"wish"}`, auth) + require.Equal(t, 200, rr.Code, rr.Body.String()) + } + require.Equal(t, int32(1), calls.Load(), "second result should come from cache") + rr = request(t, s, "DELETE", apiAIConsentPath, "", auth) + require.Equal(t, 200, rr.Code) + require.False(t, consentStatus(t, s, auth).Consented) + rr = request(t, s, "POST", "/v1/ai/plan", `{"prompt":"wish"}`, auth) + require.Equal(t, 403, rr.Code, "revocation must block cached responses too") + require.Equal(t, int32(1), calls.Load()) + // A supplied account ID is ignored: the authenticated owner is authoritative. + alice, err := s.userManager.User("alice") + require.NoError(t, err) + rr = request(t, s, "POST", apiAIConsentPath, `{"accepted":true,"configuration":"`+status.Configuration+`","user_id":"`+alice.ID+`"}`, consentAuth("bob")) + require.Equal(t, 200, rr.Code) + require.False(t, consentStatus(t, s, auth).Consented) + require.True(t, consentStatus(t, s, consentAuth("bob")).Consented) +} + +func TestServer_AIConsent_ScopedAgentCannotConsentForOwner(t *testing.T) { + s := newConsentTestServer(t) + s.ai.Mock().EnqueueText(`{"subscriptions":[{"topic":"alerts"}]}`) + u, err := s.userManager.User("alice") + require.NoError(t, err) + for _, scope := range []string{user.TokenScopeAI, user.TokenScopeRead, user.TokenScopeDevice} { + t.Run(scope, func(t *testing.T) { + token, err := s.userManager.CreateToken(u.ID, scope, time.Unix(0, 0), netip.IPv4Unspecified(), false, scope) + require.NoError(t, err) + auth := map[string]string{"Authorization": util.BearerAuth(token.Value), "Content-Type": "application/json"} + if scope == user.TokenScopeAI { + status := consentStatus(t, s, auth) + require.False(t, status.CanManage) + } + for _, method := range []string{"POST", "DELETE"} { + rr := request(t, s, method, apiAIConsentPath, `{"accepted":true,"configuration":"`+s.aiConsentConfiguration()+`"}`, auth) + require.Equal(t, 403, rr.Code, rr.Body.String()) + } + grantTestAIConsent(t, s, "alice") + rr := request(t, s, "POST", "/v1/ai/plan", `{"prompt":"wish"}`, auth) + if scope == user.TokenScopeAI { + require.Equal(t, 200, rr.Code, rr.Body.String()) + } else { + require.Equal(t, 403, rr.Code) + } + }) + } + rr := request(t, s, "GET", apiAIConsentPath, "", nil) + require.Equal(t, 401, rr.Code) +} + +func TestServer_AIConsent_RejectsStaleAcceptanceAndCrossOriginWrites(t *testing.T) { + s := newConsentTestServer(t) + configuration := s.aiConsentConfiguration() + for _, tc := range []struct { + name, body, contentType, origin string + want int + }{ + {"not accepted", `{"accepted":false,"configuration":"` + configuration + `"}`, "application/json", "", 400}, + {"stale disclosure", `{"accepted":true,"configuration":"old"}`, "application/json", "", 409}, + {"form CSRF", `{"accepted":true,"configuration":"` + configuration + `"}`, "application/x-www-form-urlencoded", "", 400}, + {"text CSRF", `{"accepted":true,"configuration":"` + configuration + `"}`, "text/plain", "", 400}, + {"cross origin", `{"accepted":true,"configuration":"` + configuration + `"}`, "application/json", "https://evil.example", 403}, + {"null origin", `{"accepted":true,"configuration":"` + configuration + `"}`, "application/json", "null", 403}, + } { + t.Run(tc.name, func(t *testing.T) { + auth := consentAuth("alice") + auth["Content-Type"], auth["Origin"] = tc.contentType, tc.origin + rr := request(t, s, "POST", apiAIConsentPath, tc.body, auth) + require.Equal(t, tc.want, rr.Code, rr.Body.String()) + require.False(t, consentStatus(t, s, consentAuth("alice")).Consented) + }) + } + auth := consentAuth("alice") + auth["Origin"] = s.config.BaseURL + rr := request(t, s, "POST", apiAIConsentPath, `{"accepted":true,"configuration":"`+configuration+`"}`, auth) + require.Equal(t, 200, rr.Code, rr.Body.String()) + auth["Origin"] = "https://evil.example" + rr = request(t, s, "DELETE", apiAIConsentPath, "", auth) + require.Equal(t, 403, rr.Code) + require.True(t, consentStatus(t, s, consentAuth("alice")).Consented) +} + +func TestServer_AIConsent_DisclosureAndConfigurationChanges(t *testing.T) { + c := newTestConfigWithAuthFile(t, "") + c.AIEnabled, c.AIProvider = true, "openai" + c.AIBaseURL, c.AIAPIKey = "https://secret-user:secret-pass@provider.example/private/path?key=secret-query", "secret-api-key" + c.AIModel, c.AIModelChat, c.AIEmbeddingsModel = "model-default", "model-chat", "model-embed" + s := newTestServer(t, c) + defer s.closeDatabases() + require.NoError(t, s.userManager.AddUser("alice", "alice", user.RoleAdmin, false)) + auth := consentAuth("alice") + status := consentStatus(t, s, auth) + require.Equal(t, "https://provider.example", status.Endpoint) + require.Equal(t, []string{"model-chat", "model-default", "model-embed"}, status.Models) + rr := request(t, s, "GET", apiAIConsentPath, "", auth) + for _, secret := range []string{"secret-user", "secret-pass", "secret-query", "secret-api-key", "/private/path"} { + require.NotContains(t, rr.Body.String(), secret) + } + grantTestAIConsent(t, s, "alice") + makeClient := func(endpoint, key string) *ai.Client { + client, err := ai.New(&ai.Config{Provider: "openai", BaseURL: endpoint, APIKey: key, Model: c.AIModel}) + require.NoError(t, err) + return client + } + s.ai = makeClient(c.AIBaseURL, "rotated-api-key") + require.Equal(t, status.Configuration, s.aiConsentConfiguration()) + require.NoError(t, s.initAIConsent()) + require.Equal(t, status.Configuration, s.aiConsentConfiguration()) + // Changing feature assignments must invalidate consent even when the model set is unchanged. + c.AIModelDigest, c.AIModelChat = "model-chat", "" + require.False(t, consentStatus(t, s, auth).Consented) + c.AIModelDigest, c.AIModelChat = "", "model-chat" + c.AIModelChat = "new-model" + require.False(t, consentStatus(t, s, auth).Consented) + c.AIModelChat = "model-chat" + s.ai = makeClient("https://other-provider.example/v1", c.AIAPIKey) + require.False(t, consentStatus(t, s, auth).Consented) + rr = request(t, s, "POST", apiAIConsentPath, `{"accepted":true,"configuration":"`+status.Configuration+`"}`, auth) + require.Equal(t, 409, rr.Code) +} + +func TestServer_AIConsent_EnrichmentAndSchedulerRequireOwnerConsent(t *testing.T) { + s := newConsentTestServer(t) + s.config.AIEnrichmentEnabled = true + s.config.AIEnrichTopics = []string{"alerts"} + var calls atomic.Int32 + s.ai.Mock().SetHandler(func(req *ai.Request) (*ai.Response, error) { + calls.Add(1) + return &ai.Response{Text: `{"summary":"AI title","headline":"Daily","sections":[]}`}, nil + }) + auth := consentAuth("alice") + for _, publisher := range []map[string]string{nil, auth} { + rr := request(t, s, "PUT", "/alerts", strings.Repeat("private alert ", 20), publisher) + require.Equal(t, 200, rr.Code) + require.Empty(t, toMessages(t, rr.Body.String())[0].Title) + } + rr := request(t, s, "POST", "/v1/account/subscription", `{"base_url":"`+s.config.BaseURL+`","topic":"alerts"}`, auth) + require.Equal(t, 200, rr.Code) + rr = request(t, s, "PATCH", "/v1/account/settings", fmt.Sprintf(`{"digest":{"enabled":true,"hour":%d}}`, time.Now().UTC().Hour()), auth) + require.Equal(t, 200, rr.Code) + s.sendDueBriefings() + u, err := s.userManager.User("alice") + require.NoError(t, err) + require.Nil(t, u.Prefs.Digest.LastDaily) + require.Zero(t, calls.Load()) + grantTestAIConsent(t, s, "alice") + u, err = s.userManager.User("alice") + require.NoError(t, err) + pubToken, err := s.userManager.CreateToken(u.ID, "publisher", time.Unix(0, 0), netip.IPv4Unspecified(), false, user.TokenScopePublish) + require.NoError(t, err) + for _, publisher := range []map[string]string{nil, consentAuth("bob"), {"Authorization": util.BearerAuth(pubToken.Value)}} { + rr = request(t, s, "PUT", "/alerts", strings.Repeat("unconsented private alert ", 20), publisher) + require.Equal(t, 200, rr.Code) + require.Empty(t, toMessages(t, rr.Body.String())[0].Title) + } + require.Zero(t, calls.Load(), "other requests on the same visitor must not borrow Alice's consent") + rr = request(t, s, "PUT", "/alerts", strings.Repeat("distinct consenting alert ", 20), auth) + require.Equal(t, 200, rr.Code) + require.Equal(t, "AI title", toMessages(t, rr.Body.String())[0].Title) + s.sendDueBriefings() + require.Equal(t, int32(2), calls.Load()) + u, err = s.userManager.User("alice") + require.NoError(t, err) + require.NotNil(t, u.Prefs.Digest.LastDaily) + require.NoError(t, s.userManager.RevokeAIConsent(context.Background(), u.ID)) + require.ErrorIs(t, s.sendDailyBriefing(u, time.Now().UTC().Format(digestDayFormat)), errHTTPForbiddenAIConsentRequired) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + allowed, err := s.hasAIConsent(ctx, u) + require.ErrorIs(t, err, context.Canceled) + require.False(t, allowed) + require.Equal(t, int32(2), calls.Load()) +} + +func TestServer_AIConsent_DatabaseFailureDoesNotForwardContent(t *testing.T) { + s := newConsentTestServer(t) + grantTestAIConsent(t, s, "alice") + var calls atomic.Int32 + s.ai.Mock().SetHandler(func(req *ai.Request) (*ai.Response, error) { + calls.Add(1) + return &ai.Response{Text: `{"subscriptions":[{"topic":"alerts"}]}`}, nil + }) + u, err := s.userManager.User("alice") + require.NoError(t, err) + require.Equal(t, 200, request(t, s, "GET", apiAIConsentPath, "", consentAuth("alice")).Code) + require.NoError(t, s.userManager.Close()) + allowed, err := s.hasAIConsent(context.Background(), u) + require.Error(t, err) + require.False(t, allowed) + rr := request(t, s, "POST", "/v1/ai/plan", `{"prompt":"private wish"}`, consentAuth("alice")) + require.GreaterOrEqual(t, rr.Code, 400) + require.Zero(t, calls.Load()) +} + +func TestServer_AIConsent_DeniedRequestsStillConsumeRequestQuota(t *testing.T) { + c := newTestConfigWithAuthFile(t, "") + c.AIEnabled, c.AIProvider = true, "mock" + c.VisitorRequestLimitBurst = 1 + c.VisitorRequestLimitReplenish = time.Hour + s := newTestServer(t, c) + defer s.closeDatabases() + require.NoError(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false)) + auth := consentAuth("alice") + rr := request(t, s, "POST", "/v1/ai/plan", `{"prompt":"wish"}`, auth) + require.Equal(t, 403, rr.Code) + rr = request(t, s, "POST", "/v1/ai/plan", `{"prompt":"wish"}`, auth) + require.Equal(t, 429, rr.Code, "consent lookups must be protected by the normal request limiter") +} diff --git a/server/server_ai_digest_test.go b/server/server_ai_digest_test.go index 47f5cf609..94e927586 100644 --- a/server/server_ai_digest_test.go +++ b/server/server_ai_digest_test.go @@ -18,6 +18,7 @@ func TestServer_AI_Digest_RequiresAuthAndSubscription(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") // Anonymous: 401 rr := request(t, s, "POST", "/v1/ai/digest", `{"topic":"backups"}`, nil) @@ -49,6 +50,7 @@ func TestServer_AI_Digest(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} rr := request(t, s, "POST", "/v1/account/subscription", `{"base_url":"`+s.config.BaseURL+`","topic":"backups"}`, auth) require.Equal(t, 200, rr.Code) @@ -95,6 +97,7 @@ func TestServer_AI_Digest_EmptyTopicReturnsZeroMessages(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} request(t, s, "POST", "/v1/account/subscription", `{"base_url":"`+s.config.BaseURL+`","topic":"empty-topic"}`, auth) diff --git a/server/server_ai_test.go b/server/server_ai_test.go index 5e3af306f..759a00f4e 100644 --- a/server/server_ai_test.go +++ b/server/server_ai_test.go @@ -22,6 +22,11 @@ func TestServer_AI_Disabled(t *testing.T) { rr := request(t, s, "GET", "/v1/ai/usage", "", nil) require.Equal(t, 400, rr.Code) require.Equal(t, 40059, toHTTPError(t, rr.Body.String()).Code) + for _, method := range []string{"GET", "POST", "DELETE"} { + rr = request(t, s, method, "/v1/ai/consent", "", nil) + require.Equal(t, 400, rr.Code) + require.Equal(t, 40059, toHTTPError(t, rr.Body.String()).Code) + } } func TestServer_AI_Usage(t *testing.T) { @@ -49,7 +54,9 @@ func TestServer_AI_Status_Admin(t *testing.T) { c.AIModel = "test-model" s := newTestServer(t, c) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + grantTestAIConsent(t, s, "ben") // Admin sees provider health and usage rr := request(t, s, "GET", "/v1/ai/status", "", map[string]string{ @@ -81,6 +88,7 @@ func TestServer_AI_Status_Unhealthy(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") s.ai.Mock().SetPingError(errors.New("connection refused")) rr := request(t, s, "GET", "/v1/ai/status", "", map[string]string{ @@ -133,6 +141,7 @@ func TestServer_AI_Plan(t *testing.T) { c.BaseURL = "http://axon.example.com" s := newTestServer(t, c) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") phil := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} s.ai.Mock().SetHandler(func(req *ai.Request) (*ai.Response, error) { // The server must tell the planner its own base URL @@ -183,6 +192,7 @@ func TestServer_AI_Plan_QuotaPerVisitor(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") phil := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} s.ai.Mock().SetHandler(func(req *ai.Request) (*ai.Response, error) { return &ai.Response{Text: `{"subscriptions": [{"topic": "t"}]}`}, nil @@ -209,6 +219,7 @@ func TestServer_AI_Plan_ProviderGarbageIs500(t *testing.T) { c.BaseURL = "http://axon.example.com" s := newTestServer(t, c) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") defer s.closeDatabases() s.ai.Mock().EnqueueText("I cannot answer in JSON, sorry!") phil := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} @@ -223,12 +234,13 @@ func TestServer_AI_Plan_BudgetExceededIs429(t *testing.T) { c.AIEnabled = true c.AIProvider = "mock" c.BaseURL = "http://axon.example.com" - c.AIGlobalDailyTokenBudget = 10 + c.AIGlobalDailyTokenBudget = 4000 s := newTestServer(t, c) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") defer s.closeDatabases() s.ai.Mock().SetHandler(func(req *ai.Request) (*ai.Response, error) { - return &ai.Response{Text: `{"subscriptions": [{"topic": "t"}]}`, InputTokens: 100, OutputTokens: 100}, nil + return &ai.Response{Text: `{"subscriptions": [{"topic": "t"}]}`, InputTokens: 2100, OutputTokens: 2100}, nil }) phil := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} rr := request(t, s, "POST", "/v1/ai/plan", `{"prompt":"wish"}`, phil) @@ -245,7 +257,7 @@ func TestServer_AI_Plan_BudgetExceededIs429(t *testing.T) { var report ai.UsageReport require.Nil(t, json.NewDecoder(rr.Body).Decode(&report)) require.Equal(t, int64(1), report.Visitor.Requests) - require.Equal(t, int64(200), report.Global.Total()) + require.Equal(t, int64(4200), report.Global.Total()) }) } @@ -257,6 +269,7 @@ func TestServer_AI_Tune(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") s.ai.Mock().SetHandler(func(req *ai.Request) (*ai.Response, error) { require.Equal(t, ai.FeatureTune, req.Feature) require.Contains(t, req.Prompt, "prod-alerts") @@ -288,6 +301,7 @@ func TestServer_AI_Plan_ContextSanitized(t *testing.T) { c.BaseURL = "http://axon.example.com" s := newTestServer(t, c) require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") phil := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} defer s.closeDatabases() var seenTurns int @@ -321,7 +335,7 @@ func TestServer_AI_Plan_ContextSanitized(t *testing.T) { } func newAIEnrichTestServer(t *testing.T, handler func(*ai.Request) (*ai.Response, error)) *Server { - c := newTestConfig(t, "") + c := newTestConfigWithAuthFile(t, "") c.AIEnabled = true c.AIProvider = "mock" c.AIEnrichmentEnabled = true @@ -330,6 +344,8 @@ func newAIEnrichTestServer(t *testing.T, handler func(*ai.Request) (*ai.Response c.AITranslateLang = "de" c.AIInlineTimeout = 2 * time.Second s := newTestServer(t, c) + require.NoError(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") s.ai.Mock().SetHandler(handler) return s } @@ -343,7 +359,7 @@ func TestServer_AI_Enrichment_Applied(t *testing.T) { }) defer s.closeDatabases() - rr := request(t, s, "PUT", "/prod-alerts", strings.Repeat("disk usage at 97% on host db-1, oldest backup failed. ", 5), nil) + rr := request(t, s, "PUT", "/prod-alerts", strings.Repeat("disk usage at 97% on host db-1, oldest backup failed. ", 5), consentAuth("phil")) require.Equal(t, 200, rr.Code) // The cached message carries the summary as its title and the suggested priority @@ -363,8 +379,9 @@ func TestServer_AI_Enrichment_NeverOverridesPublisher(t *testing.T) { // Publisher title and priority win rr := request(t, s, "PUT", "/prod-alerts", strings.Repeat("important ", 20), map[string]string{ - "Title": "Publisher title", - "Priority": "2", + "Authorization": util.BasicAuth("phil", "phil"), + "Title": "Publisher title", + "Priority": "2", }) require.Equal(t, 200, rr.Code) rr = request(t, s, "GET", "/prod-alerts/json?poll=1", "", nil) @@ -375,20 +392,22 @@ func TestServer_AI_Enrichment_NeverOverridesPublisher(t *testing.T) { } func TestServer_AI_Enrichment_PassThroughOnTimeout(t *testing.T) { - c := newTestConfig(t, "") + c := newTestConfigWithAuthFile(t, "") c.AIEnabled = true c.AIProvider = "mock" c.AIEnrichmentEnabled = true c.AIEnrichTopics = []string{"prod-alerts"} c.AIInlineTimeout = 50 * time.Millisecond s := newTestServer(t, c) + require.NoError(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") defer s.closeDatabases() s.ai.Mock().SetHandler(func(req *ai.Request) (*ai.Response, error) { time.Sleep(300 * time.Millisecond) // Slower than the inline budget return &ai.Response{Text: `{"summary": "too late"}`}, nil }) - rr := request(t, s, "PUT", "/prod-alerts", strings.Repeat("alert payload ", 20), nil) + rr := request(t, s, "PUT", "/prod-alerts", strings.Repeat("alert payload ", 20), consentAuth("phil")) require.Equal(t, 200, rr.Code) // Published anyway rr = request(t, s, "GET", "/prod-alerts/json?poll=1", "", nil) messages := toMessages(t, rr.Body.String()) @@ -404,7 +423,7 @@ func TestServer_AI_Enrichment_OptInTopicsOnly(t *testing.T) { }) defer s.closeDatabases() - rr := request(t, s, "PUT", "/other-topic", strings.Repeat("some long payload ", 20), nil) + rr := request(t, s, "PUT", "/other-topic", strings.Repeat("some long payload ", 20), consentAuth("phil")) require.Equal(t, 200, rr.Code) rr = request(t, s, "GET", "/other-topic/json?poll=1", "", nil) messages := toMessages(t, rr.Body.String()) @@ -413,7 +432,7 @@ func TestServer_AI_Enrichment_OptInTopicsOnly(t *testing.T) { require.False(t, called) // Short messages on opted-in topics are not enriched either - rr = request(t, s, "PUT", "/prod-alerts", "tiny", nil) + rr = request(t, s, "PUT", "/prod-alerts", "tiny", consentAuth("phil")) require.Equal(t, 200, rr.Code) require.False(t, called) } @@ -426,6 +445,7 @@ func TestServer_AI_TokenScopes(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") u, err := s.userManager.User("phil") require.Nil(t, err) @@ -475,7 +495,7 @@ func TestServer_AI_Enrichment_Translation(t *testing.T) { }) defer s.closeDatabases() - rr := request(t, s, "PUT", "/prod-alerts", strings.Repeat("Festplatte fast voll auf db-1. ", 5), nil) + rr := request(t, s, "PUT", "/prod-alerts", strings.Repeat("Festplatte fast voll auf db-1. ", 5), consentAuth("phil")) require.Equal(t, 200, rr.Code) rr = request(t, s, "GET", "/prod-alerts/json?poll=1", "", nil) messages := toMessages(t, rr.Body.String()) diff --git a/server/server_auth.go b/server/server_auth.go index 02690dc76..ba778c345 100644 --- a/server/server_auth.go +++ b/server/server_auth.go @@ -1,6 +1,7 @@ package server import ( + "context" "encoding/base64" "errors" "net/http" @@ -27,7 +28,7 @@ func (s *Server) maybeAuthenticate(r *http.Request) (*http.Request, *visitor, er // Stash the extracted client IP in the request context so downstream code (the abuse ban-feed in // handleError) can reuse it without re-parsing headers, and so an account-keyed (tier'd) visitor -- // whose shared visitor object has a stale v.ip -- is still attributed to the actual request IP. - r = withContext(r, map[contextKey]any{contextVisitorIP: ip}) + r = withContext(r, map[contextKey]any{contextVisitorIP: ip, contextRequestUser: (*user.User)(nil)}) vip := s.visitor(ip, nil) if s.userManager == nil { return r, vip, nil @@ -48,7 +49,9 @@ func (s *Server) maybeAuthenticate(r *http.Request) (*http.Request, *visitor, er logr(r).Err(err).Debug("Authentication failed") return r, vip, errHTTPUnauthorized // Always return visitor, even when error occurs! } - // Authentication with user was successful + // Authentication with user was successful. The visitor is a shared rate-limit + // bucket; its mutable user must never be used as this request's principal. + r = withContext(r, map[contextKey]any{contextRequestUser: u}) return r, s.visitor(ip, u), nil } @@ -109,3 +112,41 @@ func (s *Server) authenticateBearerAuth(r *http.Request, token string) (*user.Us }) return u, nil } + +// contextUser returns the per-request authentication snapshot. Calls without an +// HTTP authentication context (background jobs and direct handlers) use v.User. +func contextUser(ctx context.Context, v *visitor) *user.User { + if u, ok := ctx.Value(contextRequestUser).(*user.User); ok { + return u + } + return v.User() +} + +func requestUser(r *http.Request, v *visitor) *user.User { return contextUser(r.Context(), v) } + +func requestUserID(r *http.Request, v *visitor) string { + if u := requestUser(r, v); u != nil { + return u.ID + } + return "" +} + +// agentScopeAllowedRequest prevents a scoped agent from upgrading its credentials +// through account, device, billing or admin APIs. Device credentials have their +// own sandbox; AI routes additionally enforce the ai scope and topic ACLs. +func agentScopeAllowedRequest(u *user.User, r *http.Request) bool { + if u == nil || len(u.TokenScopes) == 0 || isDeviceScopedToken(u) { + return true + } + if !strings.HasPrefix(r.URL.Path, "/v1/") || strings.HasPrefix(r.URL.Path, "/v1/ai/") { + return true // Normal topic APIs/MCP, or AI APIs with their own scope check. + } + if r.Method != http.MethodGet { + return false + } + switch r.URL.Path { + case apiAccountPath, apiHealthPath, apiConfigPath, apiStatsPath, apiTiersPath: + return true // Account GET strips credentials for every scoped token. + } + return false +} diff --git a/server/server_auth_isolation_test.go b/server/server_auth_isolation_test.go new file mode 100644 index 000000000..c5607c153 --- /dev/null +++ b/server/server_auth_isolation_test.go @@ -0,0 +1,116 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/netip" + "testing" + "time" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/user" + "heckel.io/ntfy/v2/util" +) + +func TestServer_Authentication_RequestKeepsItsAccount(t *testing.T) { + c := newTestConfigWithAuthFile(t, "") + s := newTestServer(t, c) + defer s.closeDatabases() + require.NoError(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false)) + require.NoError(t, s.userManager.AddUser("bob", "bob", user.RoleUser, false)) + alice, _ := http.NewRequest("GET", "/v1/account", nil) + alice.RemoteAddr = "192.0.2.1:1234" + alice.Header.Set("Authorization", util.BasicAuth("alice", "alice")) + alice, v, err := s.maybeAuthenticate(alice) + require.NoError(t, err) + bob, _ := http.NewRequest("GET", "/v1/account", nil) + bob.RemoteAddr = alice.RemoteAddr + bob.Header.Set("Authorization", util.BasicAuth("bob", "bob")) + _, _, err = s.maybeAuthenticate(bob) // deterministic interleaving from the same IP + require.NoError(t, err) + rr := httptest.NewRecorder() + require.NoError(t, s.handleInternal(rr, alice, v)) + var account apiAccountResponse + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &account)) + require.Equal(t, "alice", account.Username) +} + +func TestServer_Authentication_DeviceScopeSurvivesOtherRequest(t *testing.T) { + c := newTestConfigWithAuthFile(t, "") + s := newTestServer(t, c) + defer s.closeDatabases() + require.NoError(t, s.userManager.AddUser("alice", "alice", user.RoleAdmin, false)) + owner, err := s.userManager.User("alice") + require.NoError(t, err) + token, err := s.userManager.CreateToken(owner.ID, "device", time.Unix(0, 0), netip.IPv4Unspecified(), false, user.TokenScopeDevice) + require.NoError(t, err) + device, _ := http.NewRequest("GET", "/v1/users", nil) + device.RemoteAddr = "192.0.2.1:1234" + device.Header.Set("Authorization", "Bearer "+token.Value) + device, v, err := s.maybeAuthenticate(device) + require.NoError(t, err) + admin, _ := http.NewRequest("GET", "/v1/account", nil) + admin.RemoteAddr = device.RemoteAddr + admin.Header.Set("Authorization", util.BasicAuth("alice", "alice")) + _, _, err = s.maybeAuthenticate(admin) + require.NoError(t, err) + rr := httptest.NewRecorder() + require.Equal(t, errHTTPForbidden, s.handleInternal(rr, device, v)) +} + +func TestServer_Authentication_AnonymousRequestStaysAnonymous(t *testing.T) { + c := newTestConfigWithAuthFile(t, "") + s := newTestServer(t, c) + defer s.closeDatabases() + require.NoError(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false)) + anon, _ := http.NewRequest("GET", "/v1/account", nil) + anon.RemoteAddr = "192.0.2.1:1234" + anon, v, err := s.maybeAuthenticate(anon) + require.NoError(t, err) + auth, _ := http.NewRequest("GET", "/v1/account", nil) + auth.RemoteAddr = anon.RemoteAddr + auth.Header.Set("Authorization", util.BasicAuth("alice", "alice")) + _, _, err = s.maybeAuthenticate(auth) + require.NoError(t, err) + rr := httptest.NewRecorder() + require.NoError(t, s.handleInternal(rr, anon, v)) + var account apiAccountResponse + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &account)) + require.Equal(t, user.Everyone, account.Username) + require.Empty(t, account.Tokens) +} + +func TestServer_Authentication_AIScopeSurvivesOtherRequest(t *testing.T) { + c := newTestConfigWithAuthFile(t, "") + s := newTestServer(t, c) + defer s.closeDatabases() + require.NoError(t, s.userManager.AddUser("alice", "alice", user.RoleUser, false)) + owner, err := s.userManager.User("alice") + require.NoError(t, err) + token, err := s.userManager.CreateToken(owner.ID, "read only", time.Unix(0, 0), netip.IPv4Unspecified(), false, user.TokenScopeRead) + require.NoError(t, err) + limited, _ := http.NewRequest("POST", "/v1/ai/plan", nil) + limited.RemoteAddr = "192.0.2.1:1234" + limited.Header.Set("Authorization", "Bearer "+token.Value) + limited, v, err := s.maybeAuthenticate(limited) + require.NoError(t, err) + unrestricted, _ := http.NewRequest("GET", "/v1/account", nil) + unrestricted.RemoteAddr = limited.RemoteAddr + unrestricted.Header.Set("Authorization", util.BasicAuth("alice", "alice")) + _, _, err = s.maybeAuthenticate(unrestricted) + require.NoError(t, err) + handler := s.ensureAIScope(func(http.ResponseWriter, *http.Request, *visitor) error { return nil }) + require.Equal(t, errHTTPForbidden, handler(httptest.NewRecorder(), limited, v)) +} + +func TestServer_Authentication_AnonymousTokenUpdate(t *testing.T) { + c := newTestConfigWithAuthFile(t, "") + s := newTestServer(t, c) + defer s.closeDatabases() + r, _ := http.NewRequest("PATCH", "/v1/account/token", nil) + r.RemoteAddr = "192.0.2.1:1234" + r, v, err := s.maybeAuthenticate(r) + require.NoError(t, err) + require.Equal(t, errHTTPUnauthorized, s.handleAccountTokenUpdate(httptest.NewRecorder(), r, v)) +} diff --git a/server/server_device.go b/server/server_device.go index bc7f9df2c..baddd2c5a 100644 --- a/server/server_device.go +++ b/server/server_device.go @@ -80,7 +80,7 @@ type apiDeviceResponse struct { // then handed to the human, who taps axon://pair/ on the phone. The // human tap is the consent step — minting alone grants nothing. func (s *Server) handleDevicePairingCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) if isDeviceScopedToken(u) { return errHTTPForbidden // A paired device must never grant further tokens } @@ -198,7 +198,7 @@ func (s *Server) handleDeviceAppliedAck(w http.ResponseWriter, r *http.Request, // handleDevicesList lists the user's paired devices. Tokens are never included. func (s *Server) handleDevicesList(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) if isDeviceScopedToken(u) { return errHTTPForbidden // Devices know their own ID; the fleet view is the owner's } @@ -228,7 +228,7 @@ func (s *Server) handleDeviceDelete(w http.ResponseWriter, r *http.Request, v *v if len(matches) != 2 || !deviceIDRegex.MatchString(matches[1]) { return errHTTPInternalErrorInvalidPath } - u := v.User() + u := requestUser(r, v) if isDeviceScopedToken(u) { // A device may unpair itself (self-revocation), never a sibling dev, err := s.userManager.DeviceByToken(u.Token) @@ -252,7 +252,7 @@ func (s *Server) handleDeviceDelete(w http.ResponseWriter, r *http.Request, v *v // access rule: an unrestricted (or admin) token of the owning user may address // any of their devices; a device-scoped token may only address itself. func (s *Server) deviceFromRequest(r *http.Request, v *visitor, deviceID string) (*user.Device, error) { - u := v.User() + u := requestUser(r, v) if !deviceIDRegex.MatchString(deviceID) { return nil, errHTTPInternalErrorInvalidPath } @@ -288,7 +288,7 @@ func (s *Server) handleDeviceConfigGet(w http.ResponseWriter, r *http.Request, v } w.Header().Set("Content-Type", "application/json") _, err = w.Write([]byte(dev.Config)) - if err == nil && isDeviceScopedToken(v.User()) { + if err == nil && isDeviceScopedToken(requestUser(r, v)) { _ = s.userManager.TouchDevice(dev.ID) // device synced; status only, failure harmless } return err diff --git a/server/server_digest.go b/server/server_digest.go index 9146afd53..b4f96d22a 100644 --- a/server/server_digest.go +++ b/server/server_digest.go @@ -2,6 +2,7 @@ package server import ( "context" + "errors" "fmt" "net/netip" "strings" @@ -67,6 +68,9 @@ func (s *Server) sendDueBriefings() { } today := localNow.Format(digestDayFormat) // User-local day: the dedup marker if err := s.sendDailyBriefing(u, today); err != nil { + if errors.Is(err, errHTTPForbiddenAIConsentRequired) { + continue // Leave the schedule pending until the owner consents. + } log.Tag(tagAI).Err(err).Field("user", u.Name).Warn("Daily briefing failed") continue } @@ -110,6 +114,12 @@ func digestLocation(u *user.User) *time.Location { // sendDailyBriefing gathers, generates, delivers, and marks today's briefing for one // user. Quiet days are marked but not delivered. func (s *Server) sendDailyBriefing(u *user.User, today string) error { + allowed, err := s.hasAIConsent(briefingContext(), u) + if err != nil { + return err + } else if !allowed { + return errHTTPForbiddenAIConsentRequired + } sinceHours := digestSinceFallback if u.Prefs.Digest.SinceHours != nil { sinceHours = *u.Prefs.Digest.SinceHours diff --git a/server/server_digest_test.go b/server/server_digest_test.go index c402a30a0..fcd7e72d5 100644 --- a/server/server_digest_test.go +++ b/server/server_digest_test.go @@ -21,6 +21,7 @@ func TestServer_AI_DigestScheduler(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} // Subscribe and seed history (phil is admin, so publishing works under deny-all) @@ -87,6 +88,7 @@ func TestServer_AI_DigestScheduler(t *testing.T) { // The digest topic is ACL-protected: another user cannot read it require.Nil(t, s.userManager.AddUser("ben", "ben", user.RoleUser, false)) + grantTestAIConsent(t, s, "ben") rr = request(t, s, "GET", "/"+digestTopic+"/json?poll=1", "", map[string]string{ "Authorization": util.BasicAuth("ben", "ben"), }) @@ -100,6 +102,7 @@ func TestServer_AI_DigestScheduler_QuietDay(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} request(t, s, "POST", "/v1/account/subscription", `{"base_url":"`+s.config.BaseURL+`","topic":"quiet-topic"}`, auth) rr := request(t, s, "PATCH", "/v1/account/settings", `{"digest":{"enabled":true,"hour":`+fmt.Sprintf("%d", time.Now().UTC().Hour())+`}}`, auth) @@ -129,6 +132,7 @@ func TestServer_AI_DigestScheduler_Timezone(t *testing.T) { s := newTestServer(t, c) defer s.closeDatabases() require.Nil(t, s.userManager.AddUser("phil", "phil", user.RoleAdmin, false)) + grantTestAIConsent(t, s, "phil") auth := map[string]string{"Authorization": util.BasicAuth("phil", "phil")} request(t, s, "POST", "/v1/account/subscription", `{"base_url":"`+s.config.BaseURL+`","topic":"backups"}`, auth) request(t, s, "PUT", "/backups", "backup failed", nil) diff --git a/server/server_middleware.go b/server/server_middleware.go index 117be70d4..d52483c8e 100644 --- a/server/server_middleware.go +++ b/server/server_middleware.go @@ -13,7 +13,8 @@ const ( contextRateVisitor contextKey = iota + 2586 contextTopic contextMatrixPushKey - contextVisitorIP // Client IP extracted in maybeAuthenticate; reused by the abuse ban-feed (see ban.Service.Record) + contextRequestUser // Immutable authentication result for this HTTP request (axon). + contextVisitorIP // Client IP extracted in maybeAuthenticate; reused by the abuse ban-feed (see ban.Service.Record) ) func (s *Server) limitRequests(next handleFunc) handleFunc { @@ -80,7 +81,7 @@ func (s *Server) ensureUserManager(next handleFunc) handleFunc { func (s *Server) ensureUser(next handleFunc) handleFunc { return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if v.User() == nil { + if requestUser(r, v) == nil { return errHTTPUnauthorized } return next(w, r, v) @@ -89,7 +90,7 @@ func (s *Server) ensureUser(next handleFunc) handleFunc { func (s *Server) ensureAdmin(next handleFunc) handleFunc { return s.ensureUserManager(func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if !v.User().IsAdmin() { + if !requestUser(r, v).IsAdmin() { return errHTTPUnauthorized } return next(w, r, v) @@ -125,7 +126,7 @@ func (s *Server) ensurePaymentsEnabled(next handleFunc) handleFunc { func (s *Server) ensureStripeCustomer(next handleFunc) handleFunc { return s.ensureUser(func(w http.ResponseWriter, r *http.Request, v *visitor) error { - if v.User().Billing.StripeCustomerID == "" { + if requestUser(r, v).Billing.StripeCustomerID == "" { return errHTTPBadRequestNotAPaidUser } return next(w, r, v) @@ -136,7 +137,7 @@ func (s *Server) withAccountSync(next handleFunc) handleFunc { return func(w http.ResponseWriter, r *http.Request, v *visitor) error { err := next(w, r, v) if err == nil { - s.publishSyncEventAsync(v) + s.publishSyncEventAsync(v, requestUser(r, v)) } return err } @@ -159,7 +160,7 @@ func (s *Server) authorizeTopic(next handleFunc, perm user.Permission) handleFun if err != nil { return err } - u := v.User() + u := requestUser(r, v) for _, t := range topics { if err := s.userManager.Authorize(u, t.ID, perm); err != nil { logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID) diff --git a/server/server_payments.go b/server/server_payments.go index 39007028a..f5aa0495a 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -115,7 +115,7 @@ func (s *Server) handleBillingTiersGet(w http.ResponseWriter, _ *http.Request, _ // handleAccountBillingSubscriptionCreate creates a Stripe checkout flow to create a user subscription. The tier // will be updated by a subsequent webhook from Stripe, once the subscription becomes active. func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) if u.Billing.StripeSubscriptionID != "" { return errHTTPBadRequestBillingSubscriptionExists } @@ -223,7 +223,7 @@ func checkoutSuccessAllowed(ip string) bool { // the session ID in the URL to retrieve the Stripe subscription and update the local database. This is the first // and only time we can map the local username with the Stripe customer ID. func (s *Server) handleAccountBillingSubscriptionCreateSuccess(w http.ResponseWriter, r *http.Request, v *visitor) error { - // We don't have v.User() in this endpoint, only a userManager! + // This endpoint resolves its user through the userManager. if !checkoutSuccessAllowed(v.ip.String()) { return errHTTPTooManyRequestsLimitRequests } @@ -318,7 +318,7 @@ func (s *Server) maybeEnqueueBillingEmailVerification(r *http.Request, v *visito // handleAccountBillingSubscriptionUpdate updates an existing Stripe subscription to a new price, and updates // a user's tier accordingly. This endpoint only works if there is an existing subscription. func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error { - u := v.User() + u := requestUser(r, v) if u.Billing.StripeSubscriptionID == "" { return errNoBillingSubscription } @@ -376,7 +376,7 @@ func (s *Server) handleAccountBillingSubscriptionUpdate(w http.ResponseWriter, r // That is done by a webhook at the period end (in X days). func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { logvr(v, r).Tag(tagStripe).Info("Deleting Stripe subscription") - u := v.User() + u := requestUser(r, v) if u.Billing.StripeSubscriptionID != "" { params := &stripe.SubscriptionParams{ CancelAtPeriodEnd: stripe.Bool(true), @@ -393,7 +393,7 @@ func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r // redirect URL. The billing portal allows customers to change their payment methods, and cancel the subscription. func (s *Server) handleAccountBillingPortalSessionCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { logvr(v, r).Tag(tagStripe).Info("Creating Stripe billing portal session") - u := v.User() + u := requestUser(r, v) if u.Billing.StripeCustomerID == "" { return errHTTPBadRequestNotAPaidUser } diff --git a/server/server_scoped_token_test.go b/server/server_scoped_token_test.go new file mode 100644 index 000000000..81ccbd4d0 --- /dev/null +++ b/server/server_scoped_token_test.go @@ -0,0 +1,52 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/netip" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "heckel.io/ntfy/v2/user" +) + +func TestServer_ScopedToken_CannotEscalate(t *testing.T) { + for _, scope := range []string{user.TokenScopeRead, user.TokenScopePublish, user.TokenScopeAI} { + t.Run(scope, func(t *testing.T) { + c := newTestConfigWithAuthFile(t, "") + s := newTestServer(t, c) + defer s.closeDatabases() + require.NoError(t, s.userManager.AddUser("alice", "alice", user.RoleAdmin, false)) + u, err := s.userManager.User("alice") + require.NoError(t, err) + _, err = s.userManager.CreateToken(u.ID, "unrestricted", time.Unix(0, 0), netip.IPv4Unspecified(), false, "") + require.NoError(t, err) + token, err := s.userManager.CreateToken(u.ID, "agent", time.Unix(0, 0), netip.IPv4Unspecified(), false, scope) + require.NoError(t, err) + for _, path := range []string{"/v1/account/token", "/v1/account/login", "/v1/device/pairing", "/v1/users"} { + t.Run(path, func(t *testing.T) { + r, _ := http.NewRequest("POST", path, strings.NewReader("{}")) + r.RemoteAddr = "192.0.2.1:1234" + r.Header.Set("Authorization", "Bearer "+token.Value) + r, v, err := s.maybeAuthenticate(r) + require.NoError(t, err) + require.Equal(t, errHTTPForbidden, s.handleInternal(httptest.NewRecorder(), r, v), path) + }) + } + r, _ := http.NewRequest("GET", "/v1/account", nil) + r.RemoteAddr = "192.0.2.1:1234" + r.Header.Set("Authorization", "Bearer "+token.Value) + r, v, err := s.maybeAuthenticate(r) + require.NoError(t, err) + rr := httptest.NewRecorder() + require.NoError(t, s.handleInternal(rr, r, v)) + var account apiAccountResponse + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &account)) + require.Equal(t, "alice", account.Username) + require.Empty(t, account.Tokens) + }) + } +} diff --git a/server/server_webpush.go b/server/server_webpush.go index 3b7f0f552..555a91163 100644 --- a/server/server_webpush.go +++ b/server/server_webpush.go @@ -61,7 +61,7 @@ func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v * return err } if s.userManager != nil { - u := v.User() + u := requestUser(r, v) for _, t := range topics { if err := s.userManager.Authorize(u, t.ID, user.PermissionRead); err != nil { logvr(v, r).With(t).Err(err).Debug("Access to topic %s not authorized", t.ID) @@ -69,7 +69,7 @@ func (s *Server) handleWebPushUpdate(w http.ResponseWriter, r *http.Request, v * } } } - if err := s.webPush.UpsertSubscription(req.Endpoint, req.Auth, req.P256dh, v.MaybeUserID(), v.IP(), req.Topics); err != nil { + if err := s.webPush.UpsertSubscription(req.Endpoint, req.Auth, req.P256dh, requestUserID(r, v), v.IP(), req.Topics); err != nil { return err } return s.writeJSON(w, newSuccessResponse()) diff --git a/user/manager_ai.go b/user/manager_ai.go new file mode 100644 index 000000000..388dec0cb --- /dev/null +++ b/user/manager_ai.go @@ -0,0 +1,70 @@ +package user + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "time" +) + +// AIConsent records an account owner's acceptance of a specific provider configuration. +// It is separate from preferences so a concurrent settings update cannot undo revocation. +type AIConsent struct { + ProviderConfig string + GrantedAt time.Time +} + +// AIConsent loads the account's current consent from the primary; nil means none. +func (a *Manager) AIConsent(ctx context.Context, userID string) (*AIConsent, error) { + var configuration string + var granted int64 + err := a.db.QueryRowContext(ctx, a.queries.selectAIConsent, userID).Scan(&configuration, &granted) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } else if err != nil { + return nil, err + } + return &AIConsent{ProviderConfig: configuration, GrantedAt: time.Unix(granted, 0)}, nil +} + +// GrantAIConsent records explicit owner consent; providerConfig is an opaque 256-bit identity. +func (a *Manager) GrantAIConsent(ctx context.Context, userID, providerConfig string) error { + if bytes, err := hex.DecodeString(providerConfig); err != nil || len(bytes) != 32 { + return errors.New("invalid AI provider configuration identity") + } + _, err := a.db.ExecContext(ctx, a.queries.upsertAIConsent, userID, providerConfig, time.Now().Unix()) + return err +} + +// RevokeAIConsent prevents subsequent provider calls for this account. +func (a *Manager) RevokeAIConsent(ctx context.Context, userID string) error { + _, err := a.db.ExecContext(ctx, a.queries.deleteAIConsent, userID) + return err +} + +// AIConsentSecret returns the persistent HMAC key for opaque provider identities. +// A keyed identity does not expose a brute-forceable hash of secret URL components. +func (a *Manager) AIConsentSecret(ctx context.Context) ([]byte, error) { + var encoded string + err := a.db.QueryRowContext(ctx, a.queries.selectAIConsentSecret).Scan(&encoded) + if errors.Is(err, sql.ErrNoRows) { + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return nil, err + } + if _, err := a.db.ExecContext(ctx, a.queries.insertAIConsentSecret, hex.EncodeToString(secret)); err != nil { + return nil, err + } + err = a.db.QueryRowContext(ctx, a.queries.selectAIConsentSecret).Scan(&encoded) + } + if err != nil { + return nil, err + } + secret, err := hex.DecodeString(encoded) + if err != nil || len(secret) != 32 { + return nil, errors.New("invalid AI consent metadata") + } + return secret, nil +} diff --git a/user/manager_ai_test.go b/user/manager_ai_test.go new file mode 100644 index 000000000..a1ec81cf3 --- /dev/null +++ b/user/manager_ai_test.go @@ -0,0 +1,144 @@ +package user + +import ( + "context" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestManager_AIConsent_AccountIsolationAndRevocation(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.NoError(t, a.AddUser("alice", "alice", RoleUser, false)) + require.NoError(t, a.AddUser("bob", "bob", RoleUser, false)) + alice, err := a.User("alice") + require.NoError(t, err) + bob, err := a.User("bob") + require.NoError(t, err) + ctx := context.Background() + consent, err := a.AIConsent(ctx, alice.ID) + require.NoError(t, err) + require.Nil(t, consent) + configuration := strings.Repeat("a", 64) + require.NoError(t, a.GrantAIConsent(ctx, alice.ID, configuration)) + consent, err = a.AIConsent(ctx, alice.ID) + require.NoError(t, err) + require.Equal(t, configuration, consent.ProviderConfig) + require.False(t, consent.GrantedAt.IsZero()) + consent, err = a.AIConsent(ctx, bob.ID) + require.NoError(t, err) + require.Nil(t, consent) + require.NoError(t, a.RevokeAIConsent(ctx, alice.ID)) + language := "en" + require.NoError(t, a.ChangeSettings(alice.ID, &Prefs{Language: &language})) + consent, err = a.AIConsent(ctx, alice.ID) + require.NoError(t, err) + require.Nil(t, consent, "settings must not restore revoked consent") + require.NoError(t, a.RevokeAIConsent(ctx, alice.ID)) + }) +} + +func TestManager_AIConsent_PersistsAcrossManagers(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.NoError(t, a.AddUser("alice", "alice", RoleUser, false)) + alice, err := a.User("alice") + require.NoError(t, err) + configuration := strings.Repeat("b", 64) + require.NoError(t, a.GrantAIConsent(context.Background(), alice.ID, configuration)) + b := newTestManager(t, newManager, PermissionDenyAll) + consent, err := b.AIConsent(context.Background(), alice.ID) + require.NoError(t, err) + require.Equal(t, configuration, consent.ProviderConfig) + }) +} + +func TestManager_AIConsent_MigrationFrom12StartsWithoutConsent(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.NoError(t, a.AddUser("alice", "alice", RoleUser, false)) + alice, err := a.User("alice") + require.NoError(t, err) + _, err = a.db.Exec(`DROP TABLE user_ai_consent`) + require.NoError(t, err) + _, err = a.db.Exec(`DROP TABLE user_ai_consent_meta`) + require.NoError(t, err) + if strings.HasSuffix(t.Name(), "/postgres") { + _, err = a.db.Exec(`UPDATE schema_version SET version = 12 WHERE store = 'user'`) + } else { + _, err = a.db.Exec(`UPDATE schemaVersion SET version = 12 WHERE id = 1`) + } + require.NoError(t, err) + require.NoError(t, a.Close()) + b := newTestManager(t, newManager, PermissionDenyAll) + consent, err := b.AIConsent(context.Background(), alice.ID) + require.NoError(t, err) + require.Nil(t, consent, "upgrading an existing account must not imply consent") + secret, err := b.AIConsentSecret(context.Background()) + require.NoError(t, err) + require.Len(t, secret, 32) + require.NoError(t, b.GrantAIConsent(context.Background(), alice.ID, strings.Repeat("a", 64))) + c := newTestManager(t, newManager, PermissionDenyAll) + consent, err = c.AIConsent(context.Background(), alice.ID) + require.NoError(t, err) + require.NotNil(t, consent, "reopening must not rerun the migration") + }) +} + +func TestManager_AIConsent_SecretStableUnderConcurrentInitialization(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + b := newTestManager(t, newManager, PermissionDenyAll) + type result struct { + secret []byte + err error + } + results := make(chan result, 8) + var wg sync.WaitGroup + for i := 0; i < cap(results); i++ { + wg.Add(1) + go func(manager *Manager) { + defer wg.Done() + secret, err := manager.AIConsentSecret(context.Background()) + results <- result{secret, err} + }([]*Manager{a, b}[i%2]) + } + wg.Wait() + close(results) + var first []byte + for r := range results { + require.NoError(t, r.err) + require.Len(t, r.secret, 32) + if first == nil { + first = r.secret + } + require.Equal(t, first, r.secret) + } + c := newTestManager(t, newManager, PermissionDenyAll) + secret, err := c.AIConsentSecret(context.Background()) + require.NoError(t, err) + require.Equal(t, first, secret) + }) +} + +func TestManager_AIConsent_CancellationAndInvalidIdentity(t *testing.T) { + forEachBackend(t, func(t *testing.T, newManager newManagerFunc) { + a := newTestManager(t, newManager, PermissionDenyAll) + require.NoError(t, a.AddUser("alice", "alice", RoleUser, false)) + u, err := a.User("alice") + require.NoError(t, err) + require.Error(t, a.GrantAIConsent(context.Background(), u.ID, "not a configuration")) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = a.AIConsent(ctx, u.ID) + require.ErrorIs(t, err, context.Canceled) + require.ErrorIs(t, a.GrantAIConsent(ctx, u.ID, strings.Repeat("a", 64)), context.Canceled) + require.ErrorIs(t, a.RevokeAIConsent(ctx, u.ID), context.Canceled) + consent, err := a.AIConsent(context.Background(), u.ID) + require.NoError(t, err) + require.Nil(t, consent) + }) +} diff --git a/user/manager_postgres.go b/user/manager_postgres.go index 49a35da98..4b68419ae 100644 --- a/user/manager_postgres.go +++ b/user/manager_postgres.go @@ -308,60 +308,66 @@ var postgresQueries = queries{ deleteUserAccessProvisioned: postgresDeleteUserAccessProvisionedQuery, deleteTopicAccess: postgresDeleteTopicAccessQuery, deleteAllAccess: postgresDeleteAllAccessQuery, - selectToken: postgresSelectTokenQuery, - selectTokenScopesByValue: postgresSelectTokenScopesByValueQuery, - selectTokens: postgresSelectTokensQuery, - selectTokenCount: postgresSelectTokenCountQuery, - selectAllProvisionedTokens: postgresSelectAllProvisionedTokensQuery, - upsertToken: postgresUpsertTokenQuery, - insertDevice: postgresInsertDeviceQuery, - selectDevices: postgresSelectDevicesQuery, - selectDeviceByID: postgresSelectDeviceByIDQuery, - selectDeviceByToken: postgresSelectDeviceByTokenQuery, - updateDeviceConfig: postgresUpdateDeviceConfigQuery, - updateDeviceLastSeen: postgresUpdateDeviceLastSeenQuery, - updateDeviceApplied: postgresUpdateDeviceAppliedQuery, - deleteDevice: postgresDeleteDeviceQuery, - insertPairingCode: postgresInsertPairingCodeQuery, - selectPairingCode: postgresSelectPairingCodeQuery, - usePairingCode: postgresUsePairingCodeQuery, - deleteExpiredPairing: postgresDeleteExpiredPairingQuery, - selectPairingUserID: postgresSelectPairingUserIDQuery, - selectDeviceCount: postgresSelectDeviceCountQuery, - lockDeviceUser: postgresLockDeviceUserQuery, - updateToken: postgresUpdateTokenQuery, - updateTokenLastAccess: postgresUpdateTokenLastAccessQuery, - deleteToken: postgresDeleteTokenQuery, - deleteProvisionedToken: postgresDeleteProvisionedTokenQuery, - deleteAllProvisionedTokens: postgresDeleteAllProvisionedTokensQuery, - deleteAllToken: postgresDeleteAllTokenQuery, - deleteExpiredTokens: postgresDeleteExpiredTokensQuery, - deleteExcessTokens: postgresDeleteExcessTokensQuery, - insertTier: postgresInsertTierQuery, - selectTiers: postgresSelectTiersQuery, - selectTierByCode: postgresSelectTierByCodeQuery, - selectTierByPriceID: postgresSelectTierByPriceIDQuery, - updateTier: postgresUpdateTierQuery, - deleteTier: postgresDeleteTierQuery, - selectPhoneNumbers: postgresSelectPhoneNumbersQuery, - insertPhoneNumber: postgresInsertPhoneNumberQuery, - deletePhoneNumber: postgresDeletePhoneNumberQuery, - selectEmails: postgresSelectEmailsQuery, - insertEmail: postgresInsertEmailQuery, - insertEmailIgnore: postgresInsertEmailIgnoreQuery, - deleteEmail: postgresDeleteEmailQuery, - selectPrimaryEmail: postgresSelectPrimaryEmailQuery, - selectUserIDByPrimary: postgresSelectUserIDByPrimaryQuery, - updateEmailSetPrimary: postgresUpdateEmailSetPrimaryQuery, - updateEmailClearPrimary: postgresUpdateEmailClearPrimaryQuery, - insertMagicLink: postgresInsertMagicLinkQuery, - selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery, - deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery, - deleteMagicLinkEmailVerify: postgresDeleteVerifyScopeQuery, - deleteMagicLinkResetPassword: postgresDeleteResetScopeQuery, - selectPendingEmails: postgresSelectPendingEmailsQuery, - deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery, - updateBilling: postgresUpdateBillingQuery, + selectAIConsent: `SELECT provider_config, granted_at FROM user_ai_consent WHERE user_id = $1`, + upsertAIConsent: `INSERT INTO user_ai_consent (user_id, provider_config, granted_at) VALUES ($1, $2, $3) ON CONFLICT (user_id) DO UPDATE SET provider_config = excluded.provider_config, granted_at = excluded.granted_at`, + deleteAIConsent: `DELETE FROM user_ai_consent WHERE user_id = $1`, + selectAIConsentSecret: `SELECT secret FROM user_ai_consent_meta WHERE id = 1`, + insertAIConsentSecret: `INSERT INTO user_ai_consent_meta (id, secret) VALUES (1, $1) ON CONFLICT (id) DO NOTHING`, + + selectToken: postgresSelectTokenQuery, + selectTokenScopesByValue: postgresSelectTokenScopesByValueQuery, + selectTokens: postgresSelectTokensQuery, + selectTokenCount: postgresSelectTokenCountQuery, + selectAllProvisionedTokens: postgresSelectAllProvisionedTokensQuery, + upsertToken: postgresUpsertTokenQuery, + insertDevice: postgresInsertDeviceQuery, + selectDevices: postgresSelectDevicesQuery, + selectDeviceByID: postgresSelectDeviceByIDQuery, + selectDeviceByToken: postgresSelectDeviceByTokenQuery, + updateDeviceConfig: postgresUpdateDeviceConfigQuery, + updateDeviceLastSeen: postgresUpdateDeviceLastSeenQuery, + updateDeviceApplied: postgresUpdateDeviceAppliedQuery, + deleteDevice: postgresDeleteDeviceQuery, + insertPairingCode: postgresInsertPairingCodeQuery, + selectPairingCode: postgresSelectPairingCodeQuery, + usePairingCode: postgresUsePairingCodeQuery, + deleteExpiredPairing: postgresDeleteExpiredPairingQuery, + selectPairingUserID: postgresSelectPairingUserIDQuery, + selectDeviceCount: postgresSelectDeviceCountQuery, + lockDeviceUser: postgresLockDeviceUserQuery, + updateToken: postgresUpdateTokenQuery, + updateTokenLastAccess: postgresUpdateTokenLastAccessQuery, + deleteToken: postgresDeleteTokenQuery, + deleteProvisionedToken: postgresDeleteProvisionedTokenQuery, + deleteAllProvisionedTokens: postgresDeleteAllProvisionedTokensQuery, + deleteAllToken: postgresDeleteAllTokenQuery, + deleteExpiredTokens: postgresDeleteExpiredTokensQuery, + deleteExcessTokens: postgresDeleteExcessTokensQuery, + insertTier: postgresInsertTierQuery, + selectTiers: postgresSelectTiersQuery, + selectTierByCode: postgresSelectTierByCodeQuery, + selectTierByPriceID: postgresSelectTierByPriceIDQuery, + updateTier: postgresUpdateTierQuery, + deleteTier: postgresDeleteTierQuery, + selectPhoneNumbers: postgresSelectPhoneNumbersQuery, + insertPhoneNumber: postgresInsertPhoneNumberQuery, + deletePhoneNumber: postgresDeletePhoneNumberQuery, + selectEmails: postgresSelectEmailsQuery, + insertEmail: postgresInsertEmailQuery, + insertEmailIgnore: postgresInsertEmailIgnoreQuery, + deleteEmail: postgresDeleteEmailQuery, + selectPrimaryEmail: postgresSelectPrimaryEmailQuery, + selectUserIDByPrimary: postgresSelectUserIDByPrimaryQuery, + updateEmailSetPrimary: postgresUpdateEmailSetPrimaryQuery, + updateEmailClearPrimary: postgresUpdateEmailClearPrimaryQuery, + insertMagicLink: postgresInsertMagicLinkQuery, + selectMagicLinkByHash: postgresSelectMagicLinkByHashQuery, + deleteMagicLinkByHash: postgresDeleteMagicLinkByHashQuery, + deleteMagicLinkEmailVerify: postgresDeleteVerifyScopeQuery, + deleteMagicLinkResetPassword: postgresDeleteResetScopeQuery, + selectPendingEmails: postgresSelectPendingEmailsQuery, + deleteExpiredMagicLinks: postgresDeleteExpiredMagicLinksQuery, + updateBilling: postgresUpdateBillingQuery, } // NewPostgresManager creates a new Manager backed by a PostgreSQL database diff --git a/user/manager_postgres_schema.go b/user/manager_postgres_schema.go index 53c9ee579..9f4760df1 100644 --- a/user/manager_postgres_schema.go +++ b/user/manager_postgres_schema.go @@ -91,7 +91,7 @@ const ( ); CREATE INDEX idx_magic_link_user_kind ON user_magic_link (user_id, kind); INSERT INTO "user" (id, user_name, pass, role, sync_topic, provisioned, created) - VALUES ('` + everyoneID + `', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT) + VALUES ('` + postgresAIConsentTableQueries + everyoneID + `', '*', '', 'anonymous', '', false, EXTRACT(EPOCH FROM NOW())::BIGINT) ON CONFLICT (id) DO NOTHING; CREATE TABLE IF NOT EXISTS user_device ( id TEXT PRIMARY KEY, @@ -124,7 +124,7 @@ const ( const ( // axon: fork migration 9 -> 10 adds token scopes (see sqlite side) - postgresCurrentSchemaVersion = 12 + postgresCurrentSchemaVersion = 13 ) const ( @@ -177,6 +177,7 @@ var ( 9: schema.AsMigrateFunc(postgresMigrate9To10UpdateQueries), // axon 10: schema.AsMigrateFunc(postgresMigrate10To11UpdateQueries), // axon: devices + pairing 11: schema.AsMigrateFunc(postgresMigrate11To12UpdateQueries), // axon: applied-config ack + 12: schema.AsMigrateFunc(postgresAIConsentTableQueries), // axon: explicit AI consent } ) @@ -207,3 +208,14 @@ const postgresMigrate10To11UpdateQueries = ` ); CREATE INDEX idx_user_pairing_code_expiry ON user_pairing_code (expires_at); ` + +const postgresAIConsentTableQueries = ` + CREATE TABLE IF NOT EXISTS user_ai_consent ( + user_id TEXT PRIMARY KEY NOT NULL REFERENCES "user" (id) ON DELETE CASCADE, + provider_config TEXT NOT NULL, + granted_at BIGINT NOT NULL + ); + CREATE TABLE IF NOT EXISTS user_ai_consent_meta ( + id INTEGER PRIMARY KEY CHECK (id = 1), + secret TEXT NOT NULL + );` diff --git a/user/manager_sqlite.go b/user/manager_sqlite.go index aeade88cd..3585963e1 100644 --- a/user/manager_sqlite.go +++ b/user/manager_sqlite.go @@ -304,60 +304,66 @@ var sqliteQueries = queries{ deleteUserAccessProvisioned: sqliteDeleteUserAccessProvisionedQuery, deleteTopicAccess: sqliteDeleteTopicAccessQuery, deleteAllAccess: sqliteDeleteAllAccessQuery, - selectToken: sqliteSelectTokenQuery, - selectTokenScopesByValue: sqliteSelectTokenScopesByValueQuery, - selectTokens: sqliteSelectTokensQuery, - selectTokenCount: sqliteSelectTokenCountQuery, - selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery, - upsertToken: sqliteUpsertTokenQuery, - insertDevice: sqliteInsertDeviceQuery, - selectDevices: sqliteSelectDevicesQuery, - selectDeviceByID: sqliteSelectDeviceByIDQuery, - selectDeviceByToken: sqliteSelectDeviceByTokenQuery, - updateDeviceConfig: sqliteUpdateDeviceConfigQuery, - updateDeviceLastSeen: sqliteUpdateDeviceLastSeenQuery, - updateDeviceApplied: sqliteUpdateDeviceAppliedQuery, - deleteDevice: sqliteDeleteDeviceQuery, - insertPairingCode: sqliteInsertPairingCodeQuery, - selectPairingCode: sqliteSelectPairingCodeQuery, - usePairingCode: sqliteUsePairingCodeQuery, - deleteExpiredPairing: sqliteDeleteExpiredPairingQuery, - selectPairingUserID: sqliteSelectPairingUserIDQuery, - selectDeviceCount: sqliteSelectDeviceCountQuery, - lockDeviceUser: sqliteLockDeviceUserQuery, - updateToken: sqliteUpdateTokenQuery, - updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery, - deleteToken: sqliteDeleteTokenQuery, - deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery, - deleteAllProvisionedTokens: sqliteDeleteAllProvisionedTokensQuery, - deleteAllToken: sqliteDeleteAllTokenQuery, - deleteExpiredTokens: sqliteDeleteExpiredTokensQuery, - deleteExcessTokens: sqliteDeleteExcessTokensQuery, - insertTier: sqliteInsertTierQuery, - selectTiers: sqliteSelectTiersQuery, - selectTierByCode: sqliteSelectTierByCodeQuery, - selectTierByPriceID: sqliteSelectTierByPriceIDQuery, - updateTier: sqliteUpdateTierQuery, - deleteTier: sqliteDeleteTierQuery, - selectPhoneNumbers: sqliteSelectPhoneNumbersQuery, - insertPhoneNumber: sqliteInsertPhoneNumberQuery, - deletePhoneNumber: sqliteDeletePhoneNumberQuery, - selectEmails: sqliteSelectEmailsQuery, - insertEmail: sqliteInsertEmailQuery, - insertEmailIgnore: sqliteInsertEmailIgnoreQuery, - deleteEmail: sqliteDeleteEmailQuery, - selectPrimaryEmail: sqliteSelectPrimaryEmailQuery, - selectUserIDByPrimary: sqliteSelectUserIDByPrimaryQuery, - updateEmailSetPrimary: sqliteUpdateEmailSetPrimaryQuery, - updateEmailClearPrimary: sqliteUpdateEmailClearPrimaryQuery, - insertMagicLink: sqliteInsertMagicLinkQuery, - selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery, - deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery, - deleteMagicLinkEmailVerify: sqliteDeleteVerifyScopeQuery, - deleteMagicLinkResetPassword: sqliteDeleteResetScopeQuery, - selectPendingEmails: sqliteSelectPendingEmailsQuery, - deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery, - updateBilling: sqliteUpdateBillingQuery, + selectAIConsent: `SELECT provider_config, granted_at FROM user_ai_consent WHERE user_id = ?`, + upsertAIConsent: `INSERT INTO user_ai_consent (user_id, provider_config, granted_at) VALUES (?, ?, ?) ON CONFLICT (user_id) DO UPDATE SET provider_config = excluded.provider_config, granted_at = excluded.granted_at`, + deleteAIConsent: `DELETE FROM user_ai_consent WHERE user_id = ?`, + selectAIConsentSecret: `SELECT secret FROM user_ai_consent_meta WHERE id = 1`, + insertAIConsentSecret: `INSERT INTO user_ai_consent_meta (id, secret) VALUES (1, ?) ON CONFLICT (id) DO NOTHING`, + + selectToken: sqliteSelectTokenQuery, + selectTokenScopesByValue: sqliteSelectTokenScopesByValueQuery, + selectTokens: sqliteSelectTokensQuery, + selectTokenCount: sqliteSelectTokenCountQuery, + selectAllProvisionedTokens: sqliteSelectAllProvisionedTokensQuery, + upsertToken: sqliteUpsertTokenQuery, + insertDevice: sqliteInsertDeviceQuery, + selectDevices: sqliteSelectDevicesQuery, + selectDeviceByID: sqliteSelectDeviceByIDQuery, + selectDeviceByToken: sqliteSelectDeviceByTokenQuery, + updateDeviceConfig: sqliteUpdateDeviceConfigQuery, + updateDeviceLastSeen: sqliteUpdateDeviceLastSeenQuery, + updateDeviceApplied: sqliteUpdateDeviceAppliedQuery, + deleteDevice: sqliteDeleteDeviceQuery, + insertPairingCode: sqliteInsertPairingCodeQuery, + selectPairingCode: sqliteSelectPairingCodeQuery, + usePairingCode: sqliteUsePairingCodeQuery, + deleteExpiredPairing: sqliteDeleteExpiredPairingQuery, + selectPairingUserID: sqliteSelectPairingUserIDQuery, + selectDeviceCount: sqliteSelectDeviceCountQuery, + lockDeviceUser: sqliteLockDeviceUserQuery, + updateToken: sqliteUpdateTokenQuery, + updateTokenLastAccess: sqliteUpdateTokenLastAccessQuery, + deleteToken: sqliteDeleteTokenQuery, + deleteProvisionedToken: sqliteDeleteProvisionedTokenQuery, + deleteAllProvisionedTokens: sqliteDeleteAllProvisionedTokensQuery, + deleteAllToken: sqliteDeleteAllTokenQuery, + deleteExpiredTokens: sqliteDeleteExpiredTokensQuery, + deleteExcessTokens: sqliteDeleteExcessTokensQuery, + insertTier: sqliteInsertTierQuery, + selectTiers: sqliteSelectTiersQuery, + selectTierByCode: sqliteSelectTierByCodeQuery, + selectTierByPriceID: sqliteSelectTierByPriceIDQuery, + updateTier: sqliteUpdateTierQuery, + deleteTier: sqliteDeleteTierQuery, + selectPhoneNumbers: sqliteSelectPhoneNumbersQuery, + insertPhoneNumber: sqliteInsertPhoneNumberQuery, + deletePhoneNumber: sqliteDeletePhoneNumberQuery, + selectEmails: sqliteSelectEmailsQuery, + insertEmail: sqliteInsertEmailQuery, + insertEmailIgnore: sqliteInsertEmailIgnoreQuery, + deleteEmail: sqliteDeleteEmailQuery, + selectPrimaryEmail: sqliteSelectPrimaryEmailQuery, + selectUserIDByPrimary: sqliteSelectUserIDByPrimaryQuery, + updateEmailSetPrimary: sqliteUpdateEmailSetPrimaryQuery, + updateEmailClearPrimary: sqliteUpdateEmailClearPrimaryQuery, + insertMagicLink: sqliteInsertMagicLinkQuery, + selectMagicLinkByHash: sqliteSelectMagicLinkByHashQuery, + deleteMagicLinkByHash: sqliteDeleteMagicLinkByHashQuery, + deleteMagicLinkEmailVerify: sqliteDeleteVerifyScopeQuery, + deleteMagicLinkResetPassword: sqliteDeleteResetScopeQuery, + selectPendingEmails: sqliteSelectPendingEmailsQuery, + deleteExpiredMagicLinks: sqliteDeleteExpiredMagicLinksQuery, + updateBilling: sqliteUpdateBillingQuery, } // NewSQLiteManager creates a new Manager backed by a SQLite database diff --git a/user/manager_sqlite_schema.go b/user/manager_sqlite_schema.go index f1b9df07f..2235cfde6 100644 --- a/user/manager_sqlite_schema.go +++ b/user/manager_sqlite_schema.go @@ -133,7 +133,7 @@ const ( FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE ); CREATE INDEX idx_user_pairing_code_expiry ON user_pairing_code (expires_at); - ` + ` + sqliteAIConsentTableQueries ) const ( @@ -141,12 +141,11 @@ const ( ) const ( - // axon: fork migration 9 -> 10 adds token scopes. When upstream claims 9 -> 10 in a - // future release, renumber ours to the next free version on merge (see - // docs/ai-plan/upstream-sync.md). + // axon: fork migration 9 -> 10 adds token scopes. Preserve deployed versions; + // reconcile collisions with forward migrations (see docs/ai-plan/upstream-sync.md). // axon: fork migration 10 -> 11 adds the device registry + pairing codes // (agent channel, see docs/agents.md). - sqliteCurrentSchemaVersion = 12 + sqliteCurrentSchemaVersion = 13 ) // Schema migrations for SQLite @@ -428,6 +427,7 @@ var ( 9: schema.AsMigrateFunc(sqliteMigrate9To10UpdateQueries), // axon 10: schema.AsMigrateFunc(sqliteMigrate10To11UpdateQueries), // axon: devices + pairing 11: schema.AsMigrateFunc(sqliteMigrate11To12UpdateQueries), // axon: applied-config ack + 12: schema.AsMigrateFunc(sqliteAIConsentTableQueries), // axon: explicit AI consent } ) @@ -505,3 +505,14 @@ const sqliteMigrate10To11UpdateQueries = ` ); CREATE INDEX idx_user_pairing_code_expiry ON user_pairing_code (expires_at); ` + +const sqliteAIConsentTableQueries = ` + CREATE TABLE IF NOT EXISTS user_ai_consent ( + user_id TEXT PRIMARY KEY NOT NULL REFERENCES user (id) ON DELETE CASCADE, + provider_config TEXT NOT NULL, + granted_at INT NOT NULL + ); + CREATE TABLE IF NOT EXISTS user_ai_consent_meta ( + id INTEGER PRIMARY KEY CHECK (id = 1), + secret TEXT NOT NULL + );` diff --git a/user/types.go b/user/types.go index 7797e9474..eac1ec692 100644 --- a/user/types.go +++ b/user/types.go @@ -71,8 +71,9 @@ type Token struct { } // axon: token scopes. An empty scopes list means the token is unrestricted (backwards -// compatible with all pre-existing tokens). Non-empty scopes restrict what the token can -// do; currently only TokenScopeAI is enforced (LLM-costing endpoints). +// compatible with all pre-existing tokens). Scoped agents cannot manage credentials +// or accounts; ai gates LLM endpoints, and device has its own sandbox. Read and +// publish currently remain labels; topic ACLs still control normal message access. const ( TokenScopePublish = "publish" TokenScopeRead = "read" @@ -431,6 +432,13 @@ type queries struct { deleteTopicAccess string deleteAllAccess string + // AI consent (axon): separate from preferences so settings cannot undo revocation. + selectAIConsent string + upsertAIConsent string + deleteAIConsent string + selectAIConsentSecret string + insertAIConsentSecret string + // Token queries selectToken string selectTokens string diff --git a/web/package-lock.json b/web/package-lock.json index 794ac3797..445b4f290 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -36,12 +36,66 @@ "eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", + "jsdom": "^30.1.2", "prettier": "^3.9.9", "vite": "^8.3.0", "vite-plugin-pwa": "^1.0.0", "vitest": "^4.1.9" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.5.tgz", + "integrity": "sha512-jCpAvj8apa7SKHZYogk0rbXB3Kb0hLFrpkWF+Ap+vBA5rirItnpv9NKyAyEuDuFizvl0qwnj6JWR70RGJ0rOlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.3", + "@csstools/css-color-parser": "^4.2.6", + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.4.tgz", + "integrity": "sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1593,6 +1647,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.6.tgz", + "integrity": "sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.15.tgz", + "integrity": "sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.13.5", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", @@ -1814,6 +2021,24 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -3685,6 +3910,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", @@ -4017,6 +4252,20 @@ "node": ">=8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssjanus": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/cssjanus/-/cssjanus-2.3.1.tgz", @@ -4039,6 +4288,20 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-8.0.0.tgz", + "integrity": "sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.2" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -4110,6 +4373,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4257,6 +4527,19 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5562,6 +5845,19 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, "node_modules/html-parse-stringify": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.1.0.tgz", @@ -6056,6 +6352,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -6319,6 +6622,56 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "30.1.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.2.tgz", + "integrity": "sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.1.3", + "@asamuzakjp/dom-selector": "^9.2.4", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.15", + "@exodus/bytes": "^1.16.0", + "css-tree": "^3.2.1", + "data-urls": "^8.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.11.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.2.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -6921,6 +7274,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mdurl": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", @@ -7311,6 +7671,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8049,6 +8422,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", @@ -8670,6 +9056,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.18.tgz", + "integrity": "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.18" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.18", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.18.tgz", + "integrity": "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-7.0.0.tgz", + "integrity": "sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/trough": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", @@ -8816,6 +9248,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", @@ -9268,6 +9710,19 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, "node_modules/web-namespaces": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-1.1.4.tgz", @@ -9278,6 +9733,41 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.2.0.tgz", + "integrity": "sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.16.0", + "tr46": "^7.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9752,6 +10242,23 @@ "dev": true, "license": "ISC" }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/web/package.json b/web/package.json index a8cb66ec5..aae912aec 100644 --- a/web/package.json +++ b/web/package.json @@ -41,6 +41,7 @@ "eslint-plugin-jsx-a11y": "^6.7.1", "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", + "jsdom": "^30.1.2", "prettier": "^3.9.9", "vite": "^8.3.0", "vite-plugin-pwa": "^1.0.0", diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 40cb73600..5a4de9460 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -468,6 +468,23 @@ "web_push_unknown_notification_title": "Unknown notification received from server", "web_push_unknown_notification_body": "You may need to update axon by opening the web app", "ai_subscribe_entry_button": "Describe it with AI", + "ai_consent_title": "Review AI data sharing", + "ai_consent_provider": "Provider: {{provider}}", + "ai_consent_endpoint": "Service: {{endpoint}}", + "ai_consent_models": "Models: {{models}}", + "ai_consent_data": "AI features send your prompts, conversation history, subscription details, and selected message text and metadata to the service shown above. History search can also send text for embeddings. History features use only topics your account can read.", + "ai_consent_retention": "This service is configured by the server operator. It may be a cloud provider or a local service; its operator and provider determine data retention and processing. Ask your server operator about those terms before enabling AI.", + "ai_consent_scope": "Enabling applies to AI features for your account, including scoped agents, opted-in message enrichment and scheduled briefings. You can disable AI data sharing in Settings. Revocation blocks future requests; it cannot recall data already sent or stop requests already in progress. Service or model changes require consent again.", + "ai_consent_owner_required": "Sign in with your account credentials or an unrestricted account token to manage consent. A scoped agent cannot enable or disable AI data sharing for its owner.", + "ai_consent_accept": "Enable AI data sharing for my account", + "ai_consent_cancel": "Cancel", + "ai_consent_retry": "Retry", + "ai_consent_loading": "Loading AI privacy settings", + "ai_consent_settings_title": "AI privacy", + "ai_consent_enabled": "AI data sharing is enabled for your account.", + "ai_consent_disabled": "AI data sharing is disabled for your account.", + "ai_consent_review": "Review and enable", + "ai_consent_revoke": "Disable AI data sharing", "ai_subscribe_title": "Describe it — AI sets it up", "ai_subscribe_description": "Describe what you want to be notified about in plain language. The assistant proposes topics and filters; you review everything before it is applied.", "ai_subscribe_placeholder": "e.g. Notify me when my GitHub Actions fail, but only real failures", diff --git a/web/src/app/AiApi.js b/web/src/app/AiApi.js index 633a277ba..cb85f1dcc 100644 --- a/web/src/app/AiApi.js +++ b/web/src/app/AiApi.js @@ -5,6 +5,31 @@ import { fetchOrThrow, throwAppError } from "./errors"; // AiApi talks to the axon AI endpoints (/v1/ai/*). They only exist when the server // operator enabled the AI layer (config.enable_ai); the UI must hide itself otherwise. class AiApi { + async consent({ signal } = {}) { + const response = await fetchOrThrow(`${config.base_url}/v1/ai/consent`, { + headers: maybeWithBearerAuth({}, session.token()), + signal, + }); + return response.json(); + } + + async grantConsent(configuration) { + const response = await fetchOrThrow(`${config.base_url}/v1/ai/consent`, { + method: "POST", + headers: maybeWithBearerAuth({ "Content-Type": "application/json" }, session.token()), + body: JSON.stringify({ accepted: true, configuration }), + }); + return response.json(); + } + + async revokeConsent() { + const response = await fetchOrThrow(`${config.base_url}/v1/ai/consent`, { + method: "DELETE", + headers: maybeWithBearerAuth({}, session.token()), + }); + return response.json(); + } + async plan(prompt, locale, context = []) { const url = `${config.base_url}/v1/ai/plan`; console.log(`[AiApi] Planning subscriptions for prompt (${prompt.length} chars, ${context.length} prior turns)`); diff --git a/web/src/app/AiApi.test.js b/web/src/app/AiApi.test.js index 48220bb33..cd6db701f 100644 --- a/web/src/app/AiApi.test.js +++ b/web/src/app/AiApi.test.js @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import session from "./Session"; import aiApi from "./AiApi"; +import { AIConsentRequiredError, AIProviderChangedError } from "./errors"; // AiApi posts to the axon AI endpoints with the session token (if any) attached. // The real Session singleton is used; token() is spied per-test. @@ -114,3 +115,36 @@ describe("AiApi.chatStream", () => { await expect(aiApi.chatStream("backups", "question", {})).rejects.toThrow("Provider interrupted"); }); }); + +describe("AiApi account consent", () => { + it("loads only the authenticated account's provider disclosure", async () => { + session.token.mockReturnValue("tk_owner"); + const { signal } = new AbortController(); + fetchMock.mockResolvedValue({ status: 200, json: async () => ({ consented: false }) }); + expect(await aiApi.consent({ signal })).toEqual({ consented: false }); + expect(fetchMock).toHaveBeenCalledWith("https://axon.example.com/v1/ai/consent", { + headers: { Authorization: "Bearer tk_owner" }, + signal, + }); + }); + + it("explicitly accepts the displayed configuration and revokes separately", async () => { + session.token.mockReturnValue("tk_owner"); + fetchMock.mockResolvedValue({ status: 200, json: async () => ({}) }); + await aiApi.grantConsent("configuration-one"); + expect(fetchMock.mock.calls[0][1]).toEqual({ + method: "POST", + headers: { Authorization: "Bearer tk_owner", "Content-Type": "application/json" }, + body: JSON.stringify({ accepted: true, configuration: "configuration-one" }), + }); + await aiApi.revokeConsent(); + expect(fetchMock.mock.calls[1][1]).toEqual({ method: "DELETE", headers: { Authorization: "Bearer tk_owner" } }); + }); + + it("distinguishes consent and provider changes from authentication failures", async () => { + fetchMock.mockResolvedValue({ status: 403, json: async () => ({ code: 40302 }) }); + await expect(aiApi.plan("wish", "en")).rejects.toBeInstanceOf(AIConsentRequiredError); + fetchMock.mockResolvedValue({ status: 409, json: async () => ({ code: 40909 }) }); + await expect(aiApi.grantConsent("old")).rejects.toBeInstanceOf(AIProviderChangedError); + }); +}); diff --git a/web/src/app/errors.js b/web/src/app/errors.js index 34c86f8d2..7cb25ff42 100644 --- a/web/src/app/errors.js +++ b/web/src/app/errors.js @@ -15,6 +15,22 @@ export class UnauthorizedError extends Error { } } +export class AIConsentRequiredError extends Error { + static CODE = 40302; + + constructor() { + super("AI consent required; review AI privacy settings for your account"); + } +} + +export class AIProviderChangedError extends Error { + static CODE = 40909; + + constructor() { + super("AI provider configuration changed; review the updated disclosure before consenting"); + } +} + export class UserExistsError extends Error { static CODE = 40901; // errHTTPConflictUserExists @@ -64,14 +80,22 @@ export class EmailPrimaryElsewhereError extends Error { } export const throwAppError = async (response) => { - if (response.status === 401 || response.status === 403) { + if (response.status === 401) { console.log(`[Error] HTTP ${response.status}`, response); throw new UnauthorizedError(); } const error = await maybeToJson(response); + if (response.status === 403) { + if (error?.code === AIConsentRequiredError.CODE) { + throw new AIConsentRequiredError(); + } + throw new UnauthorizedError(); + } if (error?.code) { console.log(`[Error] HTTP ${response.status}, ntfy error ${error.code}: ${error.error || ""}`, response); - if (error.code === UserExistsError.CODE) { + if (error.code === AIProviderChangedError.CODE) { + throw new AIProviderChangedError(); + } else if (error.code === UserExistsError.CODE) { throw new UserExistsError(); } else if (error.code === TopicReservedError.CODE) { throw new TopicReservedError(); diff --git a/web/src/components/AiBriefingDialog.jsx b/web/src/components/AiBriefingDialog.jsx index c7d1e6bc1..afc31aecf 100644 --- a/web/src/components/AiBriefingDialog.jsx +++ b/web/src/components/AiBriefingDialog.jsx @@ -16,6 +16,7 @@ import { import { useTranslation } from "react-i18next"; import aiApi from "../app/AiApi"; import DialogFooter from "./DialogFooter"; +import AiConsentGate from "./AiConsentGate"; // AiBriefingDialog summarizes the recent messages across ALL of the user's topics on // this server ("what did I miss?"). Requires a logged-in user. Opened from the action @@ -31,7 +32,9 @@ const AiBriefingDialog = (props) => { const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); return ( - + + + ); }; diff --git a/web/src/components/AiChatDialog.jsx b/web/src/components/AiChatDialog.jsx index 1c1d78510..d9728c70e 100644 --- a/web/src/components/AiChatDialog.jsx +++ b/web/src/components/AiChatDialog.jsx @@ -16,6 +16,7 @@ import { import { useTranslation } from "react-i18next"; import aiApi from "../app/AiApi"; import DialogFooter from "./DialogFooter"; +import AiConsentGate from "./AiConsentGate"; // AiChatDialog answers questions about one subscription's recent history ("what failed // last night?"). Answers cite the cached messages they rely on; citations link to the @@ -25,7 +26,9 @@ const AiChatDialog = (props) => { const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); return ( - + + + ); }; diff --git a/web/src/components/AiConsentGate.jsx b/web/src/components/AiConsentGate.jsx new file mode 100644 index 000000000..1a0f2e67e --- /dev/null +++ b/web/src/components/AiConsentGate.jsx @@ -0,0 +1,134 @@ +import * as React from "react"; +import { useEffect, useRef, useState } from "react"; +import { Alert, Button, CircularProgress, DialogActions, DialogContent, DialogContentText, DialogTitle, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import aiApi from "../app/AiApi"; +import { AIProviderChangedError } from "../app/errors"; + +export const AiProviderDisclosure = ({ status }) => { + const { t } = useTranslation(); + return ( + <> + {t("ai_consent_provider", { provider: status.provider })} + {status.endpoint && ( + {t("ai_consent_endpoint", { endpoint: status.endpoint })} + )} + {t("ai_consent_models", { models: status.models.join(", ") })} + {t("ai_consent_data")} + {t("ai_consent_retention")} + {t("ai_consent_scope")} + + ); +}; + +// Feature children mount only after checking the account's current consent. +const AiConsentGate = ({ active, onClose, onConsent, children }) => { + const { t } = useTranslation(); + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [reload, setReload] = useState(0); + const mounted = useRef(false); + const generation = useRef(0); + + useEffect(() => { + generation.current += 1; + mounted.current = active; + if (!active) { + return undefined; + } + const controller = new AbortController(); + setStatus(null); + setBusy(false); + setError(""); + aiApi + .consent({ signal: controller.signal }) + .then((value) => { + if (!controller.signal.aborted) { + setStatus(value); + } + }) + .catch((e) => { + if (!controller.signal.aborted) { + setError(e.message); + } + }); + return () => { + mounted.current = false; + generation.current += 1; + controller.abort(); + }; + }, [active, reload]); + + const accept = async () => { + const requestGeneration = generation.current; + const isCurrent = () => mounted.current && generation.current === requestGeneration; + setBusy(true); + setError(""); + try { + const accepted = await aiApi.grantConsent(status.configuration); + if (isCurrent()) { + setStatus(accepted); + onConsent?.(accepted); + } + } catch (e) { + if (isCurrent()) { + setError(e.message); + // Refresh the disclosure, never automatically accept a different provider. + if (e instanceof AIProviderChangedError) { + setStatus(null); + try { + const updated = await aiApi.consent(); + if (isCurrent()) { + setStatus(updated); + } + } catch (refreshError) { + if (isCurrent()) { + setError(refreshError.message); + } + } + } + } + } finally { + if (isCurrent()) { + setBusy(false); + } + } + }; + + if (!active) { + return null; + } + if (status?.consented) { + return children; + } + return ( + <> + {t("ai_consent_title")} + + {!status && !error && } + {status && } + {status && !status.can_manage && ( + + {t("ai_consent_owner_required")} + + )} + {error && ( + + {error} + + )} + + + + {!status && error && } + + + + ); +}; + +export default AiConsentGate; diff --git a/web/src/components/AiConsentGate.test.jsx b/web/src/components/AiConsentGate.test.jsx new file mode 100644 index 000000000..addc2c273 --- /dev/null +++ b/web/src/components/AiConsentGate.test.jsx @@ -0,0 +1,153 @@ +// @vitest-environment jsdom +/* eslint-env es2021 */ +import * as React from "react"; +import { act, useEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import aiApi from "../app/AiApi"; +import { AIProviderChangedError } from "../app/errors"; +import AiConsentGate from "./AiConsentGate"; +import AiPrivacySettings from "./AiPrivacySettings"; + +vi.mock("../app/AiApi", () => ({ default: { consent: vi.fn(), grantConsent: vi.fn(), revokeConsent: vi.fn() } })); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key, values = {}) => `${key} ${Object.values(values).join(" ")}`.trim() }), +})); + +const disclosure = { + consented: false, + can_manage: true, + provider: "openai-compatible", + endpoint: "https://provider.example", + models: ["model-default", "model-embed"], + configuration: "configuration-one", +}; + +let root; +let container; +let featureWork; +const Feature = () => { + useEffect(() => { + featureWork(); + }, []); + return
feature-ready
; +}; +const button = (text) => [...container.querySelectorAll("button")].find((element) => element.textContent.trim() === text); +const renderGate = (active = true) => + act(async () => { + root.render( + + + , + ); + }); + +beforeEach(() => { + vi.resetAllMocks(); + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + featureWork = vi.fn(); + aiApi.consent.mockResolvedValue(disclosure); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); +}); + +describe("explicit account consent", () => { + it("discloses the service/models before mounting or accepting", async () => { + await renderGate(); + expect(container.textContent).toContain("https://provider.example"); + expect(container.textContent).toContain("model-default, model-embed"); + expect(featureWork).not.toHaveBeenCalled(); + expect(aiApi.grantConsent).not.toHaveBeenCalled(); + aiApi.grantConsent.mockResolvedValue({ ...disclosure, consented: true }); + await act(async () => { + button("ai_consent_accept").click(); + }); + expect(aiApi.grantConsent).toHaveBeenCalledExactlyOnceWith("configuration-one"); + expect(featureWork).toHaveBeenCalledOnce(); + expect(container.textContent).toContain("feature-ready"); + }); + + it("checks current consent before admitting previously consented accounts", async () => { + aiApi.consent.mockResolvedValue({ ...disclosure, consented: true }); + await renderGate(); + expect(featureWork).toHaveBeenCalledOnce(); + expect(aiApi.grantConsent).not.toHaveBeenCalled(); + }); + + it("requires another explicit acceptance on a provider change", async () => { + await renderGate(); + aiApi.grantConsent.mockRejectedValueOnce(new AIProviderChangedError()); + aiApi.consent.mockResolvedValue({ ...disclosure, endpoint: "https://new-provider.example", configuration: "configuration-two" }); + await act(async () => { + button("ai_consent_accept").click(); + }); + expect(container.textContent).toContain("https://new-provider.example"); + expect(featureWork).not.toHaveBeenCalled(); + expect(aiApi.grantConsent).toHaveBeenCalledTimes(1); + aiApi.grantConsent.mockResolvedValue({ ...disclosure, consented: true, configuration: "configuration-two" }); + await act(async () => { + button("ai_consent_accept").click(); + }); + expect(aiApi.grantConsent).toHaveBeenLastCalledWith("configuration-two"); + expect(featureWork).toHaveBeenCalledOnce(); + }); + + it("blocks scoped agents and failed disclosure loads", async () => { + aiApi.consent.mockResolvedValue({ ...disclosure, can_manage: false }); + await renderGate(); + expect(button("ai_consent_accept").disabled).toBe(true); + await act(async () => { + button("ai_consent_accept").click(); + }); + expect(aiApi.grantConsent).not.toHaveBeenCalled(); + await renderGate(false); + aiApi.consent.mockRejectedValue(new Error("metadata unavailable")); + await renderGate(); + expect(container.textContent).toContain("metadata unavailable"); + expect(button("ai_consent_accept").disabled).toBe(true); + expect(featureWork).not.toHaveBeenCalled(); + }); + + it("ignores acceptance from a closed, then reopened dialog", async () => { + let complete; + aiApi.grantConsent.mockReturnValue( + new Promise((resolve) => { + complete = resolve; + }), + ); + await renderGate(); + await act(async () => { + button("ai_consent_accept").click(); + }); + await renderGate(false); + aiApi.consent.mockResolvedValue({ ...disclosure, configuration: "configuration-two" }); + await renderGate(); + await act(async () => { + complete({ ...disclosure, consented: true }); + }); + expect(featureWork).not.toHaveBeenCalled(); + expect(button("ai_consent_accept").disabled).toBe(false); + }); + + it("revokes through privacy settings without modifying preferences", async () => { + aiApi.consent.mockResolvedValue({ ...disclosure, consented: true }); + aiApi.revokeConsent.mockResolvedValue(disclosure); + await act(async () => { + root.render(); + }); + await act(async () => { + button("ai_consent_revoke").click(); + }); + expect(aiApi.revokeConsent).toHaveBeenCalledOnce(); + expect(container.textContent).toContain("ai_consent_disabled"); + expect(aiApi.grantConsent).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/components/AiDigestDialog.jsx b/web/src/components/AiDigestDialog.jsx index 66a50fe20..5b6866a36 100644 --- a/web/src/components/AiDigestDialog.jsx +++ b/web/src/components/AiDigestDialog.jsx @@ -16,6 +16,7 @@ import { import { useTranslation } from "react-i18next"; import aiApi from "../app/AiApi"; import DialogFooter from "./DialogFooter"; +import AiConsentGate from "./AiConsentGate"; // AiDigestDialog summarizes the recent messages of one subscription with the server's // AI layer ("what did I miss?"). Requires a logged-in user: only synced subscriptions @@ -31,7 +32,9 @@ const AiDigestDialog = (props) => { const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); return ( - + + + ); }; diff --git a/web/src/components/AiPrivacySettings.jsx b/web/src/components/AiPrivacySettings.jsx new file mode 100644 index 000000000..f8be3ae2e --- /dev/null +++ b/web/src/components/AiPrivacySettings.jsx @@ -0,0 +1,78 @@ +import * as React from "react"; +import { useEffect, useState } from "react"; +import { Alert, Button, Card, Dialog, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import aiApi from "../app/AiApi"; +import AiConsentGate, { AiProviderDisclosure } from "./AiConsentGate"; + +const AiPrivacySettings = () => { + const { t } = useTranslation(); + const [status, setStatus] = useState(null); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + const controller = new AbortController(); + aiApi + .consent({ signal: controller.signal }) + .then((value) => { + if (!controller.signal.aborted) { + setStatus(value); + } + }) + .catch((e) => { + if (!controller.signal.aborted) { + setError(e.message); + } + }); + return () => controller.abort(); + }, []); + + const revoke = async () => { + setBusy(true); + setError(""); + try { + setStatus(await aiApi.revokeConsent()); + } catch (e) { + setError(e.message); + } finally { + setBusy(false); + } + }; + + return ( + + + {t("ai_consent_settings_title")} + + {t(status?.consented ? "ai_consent_enabled" : "ai_consent_disabled")} + {status && } + {error && ( + + {error} + + )} + {status && !status.can_manage && ( + + {t("ai_consent_owner_required")} + + )} + + setOpen(false)} maxWidth="sm" fullWidth> + setOpen(false)} + onConsent={(value) => { + setStatus(value); + setOpen(false); + }} + /> + + + ); +}; + +export default AiPrivacySettings; diff --git a/web/src/components/AiSubscribeDialog.jsx b/web/src/components/AiSubscribeDialog.jsx index d524915ac..024ac364a 100644 --- a/web/src/components/AiSubscribeDialog.jsx +++ b/web/src/components/AiSubscribeDialog.jsx @@ -18,6 +18,7 @@ import i18n from "i18next"; import aiApi from "../app/AiApi"; import { poller, subscribeTopic } from "../app/subscribe"; import DialogFooter from "./DialogFooter"; +import AiConsentGate from "./AiConsentGate"; import { validTopic } from "../app/utils"; // applicableSubscriptions returns the plan's subscriptions that can actually be applied. @@ -34,7 +35,9 @@ const AiSubscribeDialog = (props) => { const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); return ( - + + + ); }; diff --git a/web/src/components/AiTuneDialog.jsx b/web/src/components/AiTuneDialog.jsx index fe06c2c48..fcf414728 100644 --- a/web/src/components/AiTuneDialog.jsx +++ b/web/src/components/AiTuneDialog.jsx @@ -19,6 +19,7 @@ import accountApi from "../app/AccountApi"; import session from "../app/Session"; import routes from "./routes"; import DialogFooter from "./DialogFooter"; +import AiConsentGate from "./AiConsentGate"; import { UnauthorizedError } from "../app/errors"; // AiTuneDialog asks the axon planner to adjust an existing subscription ("quieter", @@ -30,7 +31,9 @@ const AiTuneDialog = (props) => { const fullScreen = useMediaQuery(theme.breakpoints.down("sm")); return ( - + + + ); }; diff --git a/web/src/components/Preferences.jsx b/web/src/components/Preferences.jsx index b239325ce..56c17f3e8 100644 --- a/web/src/components/Preferences.jsx +++ b/web/src/components/Preferences.jsx @@ -52,6 +52,7 @@ import { subscribeTopic } from "./SubscribeDialog"; import notifier from "../app/Notifier"; import { useIsLaunchedPWA, useNotificationPermissionListener } from "./hooks"; import { usePrefCache } from "./PrefCache"; +import AiPrivacySettings from "./AiPrivacySettings"; const maybeUpdateAccountSettings = async (payload) => { if (!session.exists()) { @@ -71,6 +72,7 @@ const Preferences = () => ( + {session.exists() && config.enable_ai && } {session.exists() && config.enable_ai && } diff --git a/web/src/test/setup.js b/web/src/test/setup.js index fa79bac9b..21fa9bb2a 100644 --- a/web/src/test/setup.js +++ b/web/src/test/setup.js @@ -4,11 +4,15 @@ // at import time, and config.js falls back to window.location.origin when base_url is empty. const config = { base_url: "https://ntfy.sh", disallowed_topics: ["app", "account", "settings"] }; -globalThis.window = { - location: { origin: "https://ntfy.sh" }, - config, - atob: globalThis.atob, // urlB64ToUint8Array uses window.atob; Node provides global atob -}; +if (typeof document === "undefined") { + globalThis.window = { + location: { origin: "https://ntfy.sh" }, + config, + atob: globalThis.atob, // urlB64ToUint8Array uses window.atob; Node provides global atob + }; +} else { + window.config = config; +} // In the browser, `window` IS the global object, so `window.config` is also reachable as a bare // `config` identifier -- Api.js/AccountApi.js/UserManager.js/SubscriptionManager.js rely on that. @@ -17,10 +21,12 @@ globalThis.config = config; // utils.js -> Prefs.js -> db.js -> Session.username() reads localStorage at module load. // Node has no localStorage; an in-memory stand-in is enough for the pure-logic tests. -const store = new Map(); -globalThis.localStorage = { - getItem: (key) => (store.has(key) ? store.get(key) : null), - setItem: (key, value) => store.set(key, String(value)), - removeItem: (key) => store.delete(key), - clear: () => store.clear(), -}; +if (typeof document === "undefined") { + const store = new Map(); + globalThis.localStorage = { + getItem: (key) => (store.has(key) ? store.get(key) : null), + setItem: (key, value) => store.set(key, String(value)), + removeItem: (key) => store.delete(key), + clear: () => store.clear(), + }; +}