diff --git a/README.md b/README.md index a4e1eb93..c568811b 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ See [API docs](https://mu.xyz/api) · [MCP docs](docs/MCP.md) ## Pricing -Browsing is included. AI features use credits — 20/day with every account, then pay as you go from 1p per query. +Browsing is included. AI features use credits — 100/day with every account, then pay as you go from 1p per query. - **Card** — Top up via Stripe. 1 credit = 1p. - **Crypto** — AI agents pay per-request with USDC via [x402](https://x402.org). No account needed. diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 3fac8744..8f7ef1eb 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -123,7 +123,7 @@ export DONATION_URL="https://gocardless.com/your-donation-link" ```bash # Daily AI queries per account (default: 10) -export DAILY_QUOTA="10" +export DAILY_QUOTA="100" # Credit costs per operation (default values shown) export CREDIT_COST_NEWS="1" # News search (1p) @@ -190,7 +190,7 @@ export MAIL_SELECTOR="default" | `X402_ASSETS` | `USDC,EURC` | Accepted tokens (comma-separated symbols) | | `X402_FACILITATOR_URL` | `https://x402.org/facilitator` | x402 facilitator endpoint | | `X402_NETWORK` | `eip155:8453` | Blockchain network for x402 payments | -| `DAILY_QUOTA` | `10` | Daily AI queries per account | +| `DAILY_QUOTA` | `100` | Daily AI queries per account | | `CREDIT_COST_NEWS` | `1` | Credits per news search | | `CREDIT_COST_VIDEO` | `2` | Credits per video search | | `CREDIT_COST_VIDEO_WATCH` | `0` | Credits per video watch (free by default) | diff --git a/docs/MCP.md b/docs/MCP.md index ec6dd7fd..237c3d17 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -120,7 +120,7 @@ Both return a session token. Use it in subsequent requests: Authorization: Bearer SESSION_TOKEN ``` -Every account includes **20 credits per day** and can top up with a card via Stripe. +Every account includes **100 credits per day** and can top up with a card via Stripe. ## Available Tools @@ -128,7 +128,7 @@ Every account includes **20 credits per day** and can top up with a card via Str |------|-------------|-------------| | `login` | Log in and get session token | Free | | `signup` | Create account and get session token | Free | -| `chat` | Chat with AI assistant | 3 credits | +| `chat` | Chat with AI assistant | 5 credits | | `news` | Read the latest news feed | Free | | `news_search` | Search for news articles | 1 credit | | `blog_list` | Get all blog posts | Free | @@ -210,7 +210,7 @@ curl -X POST https://mu.xyz/mcp \ | Auth header | `X-PAYMENT` | `Authorization: Bearer` | | Payment model | Per request | Pre-paid credits | | Currency | USDC | GBP | -| Daily allowance | No | 10 queries/day | +| Daily allowance | No | 100 queries/day | | Best for | Autonomous agents | Human users, MCP clients | ## Self-Hosting diff --git a/docs/SYSTEM_DESIGN.md b/docs/SYSTEM_DESIGN.md index 07fd2ffd..98fba659 100644 --- a/docs/SYSTEM_DESIGN.md +++ b/docs/SYSTEM_DESIGN.md @@ -233,7 +233,7 @@ Live financial data. Credit-based usage metering. -- **Pay as you go** — 20 credits/day included, then 1 credit = 1p +- **Pay as you go** — 100 credits/day included, then 1 credit = 1p - **Stripe payments** - Card top-up - **Quota enforcement** - Integrated with API and agent - **Transaction tracking** - Usage history diff --git a/docs/VISION.md b/docs/VISION.md index 738f89e3..2e3a8c4e 100644 --- a/docs/VISION.md +++ b/docs/VISION.md @@ -60,7 +60,7 @@ AI agents can pay per-request with USDC through the [x402 protocol](https://x402 ## Pricing - **Browsing included** — news, blogs, videos, markets -- **20 credits per day** — covers search, chat, and AI features +- **100 credits per day** — covers search, chat, and AI features - **Pay as you go** — 1 credit = 1p, top up via card - **Crypto** — AI agents pay per-request via [x402](https://x402.org) - **Self-host** — run your own instance, no restrictions diff --git a/docs/WALLET_AND_CREDITS.md b/docs/WALLET_AND_CREDITS.md index 886f4532..10f0175d 100644 --- a/docs/WALLET_AND_CREDITS.md +++ b/docs/WALLET_AND_CREDITS.md @@ -6,7 +6,7 @@ Mu is a tool, not a destination. Like Google Search in 2000 — you arrive with Credits are a straightforward way to pay for what you use. No dark patterns, no pressure to upgrade, no "unlimited" tiers that incentivize us to maximize your engagement. -- **Daily allowance**: 10 AI queries/day — enough for casual utility use +- **Daily allowance**: 100 AI queries/day — enough for daily use - **Pay-as-you-go**: Top up with a card or pay per-request with crypto - **Self-host**: Run your own instance for free, forever @@ -23,7 +23,7 @@ We charge because LLMs and APIs cost money. Here's our actual cost breakdown — ### Daily Allowance -Every account includes **10 AI queries per day**: +Every account includes **100 credits per day**: - Resets at midnight UTC - Covers news search, video search, and chat AI queries - No payment required @@ -38,7 +38,7 @@ This should be enough if you're using Mu as a utility. If you need more, pay-as- | News Summary | 1 credit (1p) | AI-generated summary | | Video Search | 2 credits (2p) | YouTube API cost | | Video Watch | Free | No value added over YouTube | -| Chat AI Query | 3 credits (3p) | LLM inference cost | +| Chat AI Query | 5 credits (5p) | LLM inference cost | | Chat Room | 1 credit (1p) | Room creation | | Places Search | 5 credits (5p) | Google Places API cost | | Places Nearby | 2 credits (2p) | Google Places API cost | @@ -53,7 +53,7 @@ This should be enough if you're using Mu as a utility. If you need more, pay-as- | User Type | Daily Allowance | Credits | Notes | |-----------|------------|---------|-------| | Guest | 0 | N/A | Must register | -| Registered | 10 queries | Pay-as-you-go | When daily allowance used | +| Registered | 100 credits | Pay-as-you-go | When daily allowance used | | Admin | Unlimited | Not needed | Site administrators | ## Why No "Unlimited" Tier? @@ -216,7 +216,7 @@ X402_NETWORK="eip155:8453" X402_ASSET="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" # Quota (optional - these are defaults) -DAILY_QUOTA="10" +DAILY_QUOTA="100" CREDIT_COST_NEWS="1" CREDIT_COST_NEWS_SUMMARY="1" CREDIT_COST_VIDEO="2" diff --git a/docs/WHITEPAPER.md b/docs/WHITEPAPER.md index 70ca5a55..c05812fb 100644 --- a/docs/WHITEPAPER.md +++ b/docs/WHITEPAPER.md @@ -138,7 +138,7 @@ Read-only operations — browsing news feeds, reading blog posts, watching video ### 3.3 Daily Quota -Each account includes a daily allocation of twenty queries, resetting at midnight UTC. This quota is sufficient for casual utility use. When the daily quota is exhausted, subsequent operations consume credits from the user's wallet. This model ensures accessibility while covering infrastructure costs for heavy usage. +Each account includes a daily allocation of one hundred queries, resetting at midnight UTC. This quota is sufficient for casual utility use. When the daily quota is exhausted, subsequent operations consume credits from the user's wallet. This model ensures accessibility while covering infrastructure costs for heavy usage. ### 3.4 Incentive Alignment diff --git a/home/cards.json b/home/cards.json index 4599d05c..d5989a13 100644 --- a/home/cards.json +++ b/home/cards.json @@ -18,11 +18,19 @@ } ], "right": [ + { + "id": "weather", + "title": "Weather", + "type": "weather", + "position": 0, + "link": "/weather", + "icon": "" + }, { "id": "markets", "title": "Markets", "type": "markets", - "position": 0, + "position": 1, "link": "/markets", "icon": "/markets.svg" }, @@ -30,7 +38,7 @@ "id": "reminder", "title": "Reminder", "type": "reminder", - "position": 1, + "position": 2, "link": "", "icon": "/reminder.svg" }, @@ -38,7 +46,7 @@ "id": "social", "title": "Social", "type": "social", - "position": 2, + "position": 3, "link": "/social", "icon": "" }, @@ -46,7 +54,7 @@ "id": "video", "title": "Video", "type": "video", - "position": 3, + "position": 4, "link": "/video", "icon": "/video.png" } diff --git a/home/home.go b/home/home.go index 48a5ac9e..ca72b641 100644 --- a/home/home.go +++ b/home/home.go @@ -23,6 +23,7 @@ import ( "mu/reminder" "mu/user" "mu/video" + "mu/weather" ) //go:embed cards.json @@ -124,6 +125,7 @@ func Load() { "video": video.Latest, "apps": apps.Preview, "social": social.CardHTML, + "weather": weather.CardHTML, } // Build Cards array from config diff --git a/internal/api/api.go b/internal/api/api.go index 85e43bc5..02d85b72 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -783,7 +783,7 @@ func init() { Name: "MCP Server", Path: "/mcp", Method: "POST", - Description: "Model Context Protocol server for AI tool integration. Supports initialize, tools/list, tools/call, and ping methods. Tools include chat, news, blog, video, mail, search, wallet, weather, places, markets, reminder, login, and signup. Metered tools (chat: 3 credits, news_search: 1 credit, video_search: 2 credits, mail_send: 4 credits, weather_forecast: 1 credit + optional 1 credit for pollen data) use the same wallet credit system as the REST API. 20 queries/day included.", + Description: "Model Context Protocol server for AI tool integration. Supports initialize, tools/list, tools/call, and ping methods. Tools include chat, news, blog, video, mail, search, wallet, weather, places, markets, reminder, login, and signup. Metered tools (chat: 5 credits, news_search: 1 credit, video_search: 2 credits, mail_send: 4 credits, weather_forecast: 1 credit + optional 1 credit for pollen data) use the same wallet credit system as the REST API. 100 credits/day included.", Params: []*Param{ { Name: "jsonrpc", diff --git a/internal/data/data.go b/internal/data/data.go index dee6387a..5b2309b7 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -43,7 +43,7 @@ func SaveFile(key, val string) error { file := filepath.Join(path, key) // Create all parent directories including subdirectories in key os.MkdirAll(filepath.Dir(file), 0700) - os.WriteFile(file, []byte(val), 0644) + os.WriteFile(file, []byte(val), 0600) return nil } diff --git a/mail/encrypt.go b/mail/encrypt.go new file mode 100644 index 00000000..bdd3fc2b --- /dev/null +++ b/mail/encrypt.go @@ -0,0 +1,230 @@ +package mail + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync" + + "mu/internal/app" +) + +const encPrefix = "enc:" // prefix to identify encrypted fields + +var ( + encKey []byte + encOnce sync.Once + encEnabled bool +) + +// initEncryption loads or generates the encryption key +func initEncryption() { + encOnce.Do(func() { + // Try env var first + if keyStr := os.Getenv("MU_ENCRYPTION_KEY"); keyStr != "" { + decoded, err := base64.StdEncoding.DecodeString(keyStr) + if err == nil && len(decoded) == 32 { + encKey = decoded + encEnabled = true + app.Log("mail", "Encryption enabled (from MU_ENCRYPTION_KEY)") + return + } + app.Log("mail", "WARNING: MU_ENCRYPTION_KEY invalid (must be 32 bytes, base64-encoded)") + } + + // Try key file + keyDir := os.ExpandEnv("$HOME/.mu/keys") + keyFile := filepath.Join(keyDir, "encryption.key") + + if data, err := os.ReadFile(keyFile); err == nil { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data))) + if err == nil && len(decoded) == 32 { + encKey = decoded + encEnabled = true + app.Log("mail", "Encryption enabled (from %s)", keyFile) + return + } + } + + // Generate new key + key := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + app.Log("mail", "WARNING: Failed to generate encryption key: %v", err) + return + } + + os.MkdirAll(keyDir, 0700) + encoded := base64.StdEncoding.EncodeToString(key) + if err := os.WriteFile(keyFile, []byte(encoded), 0600); err != nil { + app.Log("mail", "WARNING: Failed to save encryption key: %v", err) + return + } + + encKey = key + encEnabled = true + app.Log("mail", "Encryption enabled (new key generated at %s)", keyFile) + }) +} + +// encrypt encrypts plaintext using AES-256-GCM +func encrypt(plaintext string) (string, error) { + if !encEnabled || plaintext == "" { + return plaintext, nil + } + + block, err := aes.NewCipher(encKey) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return encPrefix + base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// decrypt decrypts ciphertext using AES-256-GCM +func decrypt(ciphertext string) (string, error) { + if !encEnabled || ciphertext == "" { + return ciphertext, nil + } + + // Not encrypted — return as-is (handles pre-encryption messages) + if !strings.HasPrefix(ciphertext, encPrefix) { + return ciphertext, nil + } + + data, err := base64.StdEncoding.DecodeString(ciphertext[len(encPrefix):]) + if err != nil { + return ciphertext, err + } + + block, err := aes.NewCipher(encKey) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", errors.New("ciphertext too short") + } + + nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil) + if err != nil { + return "", err + } + + return string(plaintext), nil +} + +// encryptMessage encrypts sensitive fields in a message for storage +func encryptMessage(m *Message) error { + if !encEnabled { + return nil + } + + var err error + + if m.Subject != "" && !strings.HasPrefix(m.Subject, encPrefix) { + m.Subject, err = encrypt(m.Subject) + if err != nil { + return err + } + } + + if m.Body != "" && !strings.HasPrefix(m.Body, encPrefix) { + m.Body, err = encrypt(m.Body) + if err != nil { + return err + } + } + + if m.To != "" && !strings.HasPrefix(m.To, encPrefix) { + m.To, err = encrypt(m.To) + if err != nil { + return err + } + } + + if m.ToID != "" && !strings.HasPrefix(m.ToID, encPrefix) { + m.ToID, err = encrypt(m.ToID) + if err != nil { + return err + } + } + + if m.RawHeaders != "" && !strings.HasPrefix(m.RawHeaders, encPrefix) { + m.RawHeaders, err = encrypt(m.RawHeaders) + if err != nil { + return err + } + } + + return nil +} + +// decryptMessage decrypts sensitive fields in a message after loading +func decryptMessage(m *Message) error { + if !encEnabled { + return nil + } + + var err error + + if strings.HasPrefix(m.Subject, encPrefix) { + m.Subject, err = decrypt(m.Subject) + if err != nil { + return err + } + } + + if strings.HasPrefix(m.Body, encPrefix) { + m.Body, err = decrypt(m.Body) + if err != nil { + return err + } + } + + if strings.HasPrefix(m.To, encPrefix) { + m.To, err = decrypt(m.To) + if err != nil { + return err + } + } + + if strings.HasPrefix(m.ToID, encPrefix) { + m.ToID, err = decrypt(m.ToID) + if err != nil { + return err + } + } + + if strings.HasPrefix(m.RawHeaders, encPrefix) { + m.RawHeaders, err = decrypt(m.RawHeaders) + if err != nil { + return err + } + } + + return nil +} diff --git a/mail/encrypt_test.go b/mail/encrypt_test.go new file mode 100644 index 00000000..8b0430e0 --- /dev/null +++ b/mail/encrypt_test.go @@ -0,0 +1,148 @@ +package mail + +import ( + "crypto/rand" + "io" + "strings" + "testing" +) + +func TestEncryptDecrypt(t *testing.T) { + // Set up a test key + encKey = make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, encKey); err != nil { + t.Fatal(err) + } + encEnabled = true + + tests := []string{ + "Hello, world!", + "Sensitive email content with special chars: &", + "Short", + strings.Repeat("Long message ", 1000), + "", + } + + for _, plaintext := range tests { + encrypted, err := encrypt(plaintext) + if err != nil { + t.Fatalf("encrypt(%q): %v", plaintext[:min(len(plaintext), 20)], err) + } + + if plaintext != "" && !strings.HasPrefix(encrypted, encPrefix) { + t.Fatalf("encrypted text should have prefix %q", encPrefix) + } + + if plaintext != "" && encrypted == plaintext { + t.Fatal("encrypted text should differ from plaintext") + } + + decrypted, err := decrypt(encrypted) + if err != nil { + t.Fatalf("decrypt: %v", err) + } + + if decrypted != plaintext { + t.Fatalf("roundtrip failed: got %q, want %q", decrypted[:min(len(decrypted), 50)], plaintext[:min(len(plaintext), 50)]) + } + } +} + +func TestEncryptMessage(t *testing.T) { + encKey = make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, encKey); err != nil { + t.Fatal(err) + } + encEnabled = true + + m := &Message{ + ID: "123", + From: "alice", + FromID: "alice-id", + To: "bob", + ToID: "bob-id", + Subject: "Secret meeting", + Body: "Meet me at the park", + } + + if err := encryptMessage(m); err != nil { + t.Fatal(err) + } + + // ID, From, FromID should NOT be encrypted + if strings.HasPrefix(m.ID, encPrefix) { + t.Error("ID should not be encrypted") + } + if strings.HasPrefix(m.From, encPrefix) { + t.Error("From should not be encrypted") + } + if strings.HasPrefix(m.FromID, encPrefix) { + t.Error("FromID should not be encrypted") + } + + // To, ToID, Subject, Body SHOULD be encrypted + if !strings.HasPrefix(m.Subject, encPrefix) { + t.Error("Subject should be encrypted") + } + if !strings.HasPrefix(m.Body, encPrefix) { + t.Error("Body should be encrypted") + } + if !strings.HasPrefix(m.To, encPrefix) { + t.Error("To should be encrypted") + } + if !strings.HasPrefix(m.ToID, encPrefix) { + t.Error("ToID should be encrypted") + } + + // Decrypt and verify + if err := decryptMessage(m); err != nil { + t.Fatal(err) + } + + if m.Subject != "Secret meeting" { + t.Errorf("Subject = %q, want %q", m.Subject, "Secret meeting") + } + if m.Body != "Meet me at the park" { + t.Errorf("Body = %q, want %q", m.Body, "Meet me at the park") + } + if m.To != "bob" { + t.Errorf("To = %q, want %q", m.To, "bob") + } + if m.ToID != "bob-id" { + t.Errorf("ToID = %q, want %q", m.ToID, "bob-id") + } +} + +func TestDecryptPlaintextPassthrough(t *testing.T) { + encKey = make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, encKey); err != nil { + t.Fatal(err) + } + encEnabled = true + + // Pre-encryption messages (no enc: prefix) should pass through unchanged + m := &Message{ + Subject: "Old unencrypted subject", + Body: "Old unencrypted body", + To: "charlie", + ToID: "charlie-id", + } + + if err := decryptMessage(m); err != nil { + t.Fatal(err) + } + + if m.Subject != "Old unencrypted subject" { + t.Error("Plaintext subject should pass through unchanged") + } + if m.Body != "Old unencrypted body" { + t.Error("Plaintext body should pass through unchanged") + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/mail/mail.go b/mail/mail.go index 7a748f0c..f3c8e4a5 100644 --- a/mail/mail.go +++ b/mail/mail.go @@ -68,7 +68,8 @@ type Message struct { // Load messages from disk // Load messages from disk and configure SMTP/DKIM func Load() { - // Loaded + // Initialize encryption + initEncryption() b, err := data.LoadFile("mail.json") if err != nil { @@ -80,6 +81,13 @@ func Load() { } else { app.Log("mail", "Loaded %d messages", len(messages)) + // Decrypt messages loaded from disk + for _, m := range messages { + if err := decryptMessage(m); err != nil { + app.Log("mail", "WARNING: Failed to decrypt message %s: %v", m.ID, err) + } + } + // Fix threading for any messages with broken chains fixThreading() @@ -247,7 +255,19 @@ func addMessageToInbox(inbox *Inbox, msg *Message, userID string) { // Save messages to disk (caller must hold mutex) func save() error { - b, err := json.Marshal(messages) + // Make copies with encrypted fields for storage + encrypted := make([]*Message, len(messages)) + for i, m := range messages { + cp := *m + if err := encryptMessage(&cp); err != nil { + app.Log("mail", "WARNING: Failed to encrypt message %s: %v", m.ID, err) + encrypted[i] = m // fall back to unencrypted + continue + } + encrypted[i] = &cp + } + + b, err := json.Marshal(encrypted) if err != nil { return err } diff --git a/wallet/wallet.go b/wallet/wallet.go index c8a70d05..1bc0f9b0 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -20,7 +20,7 @@ import ( var ( CostNewsSearch = getEnvInt("CREDIT_COST_NEWS", 1) CostVideoSearch = getEnvInt("CREDIT_COST_VIDEO", 2) - CostChatQuery = getEnvInt("CREDIT_COST_CHAT", 3) + CostChatQuery = getEnvInt("CREDIT_COST_CHAT", 5) CostBlogCreate = getEnvInt("CREDIT_COST_BLOG_CREATE", 1) CostMailSend = getEnvInt("CREDIT_COST_MAIL", 1) // Internal mail send CostExternalEmail = getEnvInt("CREDIT_COST_EMAIL", 4) // External email (SMTP delivery cost) @@ -35,7 +35,7 @@ var ( CostSocialSearch = getEnvInt("CREDIT_COST_SOCIAL", 1) CostAppBuild = getEnvInt("CREDIT_COST_APP_BUILD", 100) CostAppEdit = getEnvInt("CREDIT_COST_APP_EDIT", 50) - DailyQuota = getEnvInt("DAILY_QUOTA", getEnvInt("FREE_DAILY_QUOTA", 20)) + DailyQuota = getEnvInt("DAILY_QUOTA", getEnvInt("FREE_DAILY_QUOTA", 100)) ) // PaymentsEnabled returns true if payments are configured diff --git a/weather/weather.go b/weather/weather.go index 5d1cd668..78f7a7fb 100644 --- a/weather/weather.go +++ b/weather/weather.go @@ -14,6 +14,52 @@ import ( // Load initialises the weather package (placeholder for future caching). func Load() {} +// CardHTML returns the weather card for the home screen. +// Renders a shell that populates via JS using the browser's geolocation. +func CardHTML() string { + return `
+
+Checking weather... +
+ +
` +} + // Handler handles /weather requests. func Handler(w http.ResponseWriter, r *http.Request) { if app.WantsJSON(r) {