Credit balance
—One balance across web, CLI, and agents.
diff --git a/cmd/cosift/community.go b/cmd/cosift/community.go index 4351492..f2163b8 100644 --- a/cmd/cosift/community.go +++ b/cmd/cosift/community.go @@ -28,8 +28,8 @@ func runCommunity(ctx context.Context, args []string) error { backend := fs.String("backend", "http://127.0.0.1:7777", "Cosift Pebble server origin") dir := fs.String("data-dir", "./community-data", "private account database directory") proxies := fs.String("trusted-proxies", "", "comma-separated proxy CIDRs allowed to supply X-Forwarded-For") - guestInterval := fs.Duration("guest-interval", time.Minute, "shared guest allowance interval") - freeRPM := fs.Int("member-free-rpm", 60, "shared free member requests per minute") + guestInterval := fs.Duration("guest-interval", 30*time.Minute, "guest Search cooldown; Answer uses twice this interval and Research three times, shared across modes") + freeRPM := fs.Int("member-free-rpm", 0, "deprecated and ignored; every successful authenticated request costs credits") searchRPM := fs.Int("search-rpm", 120, "member Search hard cap per minute, including credit requests") answerRPM := fs.Int("answer-rpm", 20, "member Answer hard cap per minute, including credit requests") researchLimit := fs.Int("research-per-10m", 3, "member Research hard cap per ten minutes, including credit requests") @@ -56,7 +56,7 @@ func runCommunity(ctx context.Context, args []string) error { defer client.Close() provider = client } - s, err := community.Open(community.Config{GAMeasurementID: os.Getenv("COSIFT_GA_MEASUREMENT_ID"), Shared: provider, DataDir: *dir, Backend: *backend, PublicURL: *publicURL, AdminToken: os.Getenv("COSIFT_COMMUNITY_ADMIN_TOKEN"), TrustedProxies: trusted, GuestInterval: *guestInterval, MemberFreeRPM: *freeRPM, SearchRPM: *searchRPM, AnswerRPM: *answerRPM, ResearchPer10Min: *researchLimit, StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), AllowTestPayments: os.Getenv("COSIFT_ALLOW_TEST_PAYMENTS") == "1", StripePortalConfigurationID: os.Getenv("COSIFT_STRIPE_PORTAL_CONFIGURATION_ID")}) + s, err := community.Open(community.Config{GAMeasurementID: os.Getenv("COSIFT_GA_MEASUREMENT_ID"), Shared: provider, SharedPasswordEnabled: os.Getenv("COSIFT_SHARED_PASSWORD_ENABLED") == "1", DataDir: *dir, Backend: *backend, PublicURL: *publicURL, AdminToken: os.Getenv("COSIFT_COMMUNITY_ADMIN_TOKEN"), TrustedProxies: trusted, GuestInterval: *guestInterval, MemberFreeRPM: *freeRPM, SearchRPM: *searchRPM, AnswerRPM: *answerRPM, ResearchPer10Min: *researchLimit, StripeSecretKey: os.Getenv("STRIPE_SECRET_KEY"), StripeWebhookSecret: os.Getenv("STRIPE_WEBHOOK_SECRET"), AllowTestPayments: os.Getenv("COSIFT_ALLOW_TEST_PAYMENTS") == "1", StripePortalConfigurationID: os.Getenv("COSIFT_STRIPE_PORTAL_CONFIGURATION_ID")}) if err != nil { return err } diff --git a/deploy/community.env.example b/deploy/community.env.example index 646f3ab..b38a715 100644 --- a/deploy/community.env.example +++ b/deploy/community.env.example @@ -11,6 +11,9 @@ COSIFT_ALLOW_TEST_PAYMENTS=0 # Default: standalone email/password accounts. See docs/SHARED-ACCOUNTS.md. COSIFT_AUTH_MODE=local +# Opt in only after cosift-auth with shared password support is deployed. +# Passwords remain in the upstream shared account store; OTP always remains. +COSIFT_SHARED_PASSWORD_ENABLED=0 # Shared mode requires ALL four values below; database never defaults silently. COSIFT_SHARED_PROJECT= COSIFT_SHARED_DATABASE= diff --git a/docs/AGENT-SETUP.md b/docs/AGENT-SETUP.md index c78cbb5..3570576 100644 --- a/docs/AGENT-SETUP.md +++ b/docs/AGENT-SETUP.md @@ -123,23 +123,29 @@ uses server compute; local embeddings do not bypass validation. ## Allowance and credits -Web, CLI, and MCP searches share the account's gateway allowance and credit ledger. -Every account gets **60 shared free requests per minute plus 1,000 free credits -per UTC calendar month**, with no subscription required. The monthly grant is +Web, CLI, and MCP searches share the account's gateway limits and credit ledger. +Every account gets **1,000 free credits per UTC calendar month**, with no +subscription required. The monthly grant is applied once on authenticated use in the current month; inactive past months are not backfilled. Unused credits carry over. A verified new contribution earns **10 credits** once per unique content. -Rejected, unverified, duplicate, or already-indexed pages earn none. After the -shared free requests, an extra successful request spends: +Rejected, unverified, duplicate, or already-indexed pages earn none. Every +successful authenticated retrieval spends credits, starting with the first request: -| Mode | Credits | Hard cap per account | +| Mode | Credits per successful request | Hard cap per account | | --- | --- | --- | | Search | 1 | 120/minute | | Answer | 2 | 20/minute | | Research | 3 | 3/10 minutes | -Failed backend requests release reservations and refund credits. Credits do not +There is no free per-minute member bypass. Credits are reserved before backend +work; insufficient credit stops the request. Failed backend requests release +reservations and refund credits. The monthly free grant, earned credits, and +purchases all fund the same balance. Guest retrieval has one shared cooldown per +public IP: successful Search waits 30 minutes, Answer 60, and Research 90. The +cooldown blocks every mode, so changing modes does not bypass it; failed backend +requests do not consume it. Web and CLI use the same guest policy. Credits do not bypass mode limits or MCP's separate daily call cap. Check [`/api/limits`](https://cosift.pilotprotocol.network/api/limits) and the authenticated credits view for current policy. Respect retry guidance after a rate limit. diff --git a/docs/COMMUNITY-ROLLOUT.md b/docs/COMMUNITY-ROLLOUT.md index 7cefe76..721b202 100644 --- a/docs/COMMUNITY-ROLLOUT.md +++ b/docs/COMMUNITY-ROLLOUT.md @@ -116,20 +116,33 @@ reuse the withdrawn v0.2.6 artifacts. ## Default quotas and public API compatibility -| Operation | Member hard cap | Guest hard cap | -| --- | --- | --- | -| Search | 120/minute | 1/minute | -| Answer | 20/minute | 1/5 minutes | -| Research | 3/10 minutes | 1/30 minutes | - -Guests also share one request/minute across retrieval and contributions. Members -have 60 shared free requests/minute; one credit pays for each extra request -within the hard caps. Credit balance never bypasses a cap. Backend failures -release mode slots and refund guest allowances, free member reservations and -credits. Mode and shared free quotas persist across restarts. Search-only usage -can use 60 free requests and then 60 credit-funded requests/minute. Repeated CLI -commands should use `cosift login -session-file FILE` to avoid repeated password -logins and the separate authentication throttle. +| Operation | Credits per successful authenticated request | Member hard cap | Shared guest cooldown after success | +| --- | --- | --- | --- | +| Search | 1 | 120/minute | 30 minutes | +| Answer | 2 | 20/minute | 60 minutes | +| Research | 3 | 3/10 minutes | 90 minutes | + +Every successful authenticated retrieval spends credits from its first request. +Each account receives 1,000 free credits per UTC calendar month; there is no +free per-minute member bypass. Unused credits carry over. The old +`-member-free-rpm` option is deprecated and ignored. Guests share one persistent +IP cooldown across all retrieval modes. The command's `-guest-interval` defaults +to `30m`; Search/Answer/Research multiply it by 1/2/3 after success. For example, +a guest Answer also blocks Search for 60 minutes. Changing modes does not bypass +the outstanding cooldown. Backend failures release guest reservations. Web and +CLI guests share this server policy. Contributions always require authentication. + +Reserve credits and a mode slot atomically before dispatch. A failed backend +request must release its slot and refund its credit reservation; an insufficient +balance must prevent backend work. Credit balance never bypasses a hard cap, and +mode counters and the ledger persist across restarts. For rollout acceptance, +verify actual balance changes of -1/-2/-3 for a member's first Search/Answer/Research +request, and -1 for MCP search under that same account. A prior free-quota test is +not evidence that the new metering policy works. Repeat a failure and verify no +net debit; confirm the next UTC monthly grant happens only once per account. +Use the installer's private saved CLI session for repeated commands. Verify +guest cooldown duration for all three modes, cross-mode rejection, restart +persistence, and no consumption after failed backend work. Public `/search`, `/answer` and `/research` now use the portal and accept GET with `q`, matching the app/CLI. Existing public POST, streaming, or advanced @@ -161,9 +174,10 @@ are not classified. Unreadable, oversized or uncertain pages remain unverified. Obvious junk is screened before the model; the model handles broader spam and content judgments. Local embeddings are checked against server computation, so this first version does not promise server-compute savings. New content earns -10 credits, globally deduplicated by content hash. Stripe one-time credit purchases are implemented but disabled until the secret -API key and webhook signing secret are configured. See [Stripe activation and +10 credits, globally deduplicated by content hash. Stripe subscriptions and +subscriber-only top-ups require live billing configuration, verified webhooks, +and the dedicated restricted portal configuration. See [Stripe activation and test-mode checks](STRIPE.md). Shared mode verifies email codes through -`cosift-auth`; standalone local mode still lacks email verification and -self-service password reset. Article authoring and rewards for article views -remain outside this release. +`cosift-auth`; optional password setup/reset requires a fresh email code. +Standalone local mode still lacks email verification and self-service password +reset. Article authoring and rewards for article views remain outside this release. diff --git a/docs/COMMUNITY-VALIDATION.md b/docs/COMMUNITY-VALIDATION.md index 3ef6eed..dcef0da 100644 --- a/docs/COMMUNITY-VALIDATION.md +++ b/docs/COMMUNITY-VALIDATION.md @@ -94,3 +94,18 @@ require internet access. JavaScript regressions are now included in PR CI. receipt keys are included with the engine's Pebble data and must be retained alongside community ledger backups. Upgrade the backend before the portal; an older backend cannot provide durable reward receipts. + + +## Metering policy changed after this validation + +The recorded free-per-minute member allowance is historical. Current policy +charges every successful authenticated Search/Answer/Research request 1/2/3 +credits, including the first request, and grants each account 1,000 free credits +per UTC month. Guest limits also changed after the original validation: a +successful Search/Answer/Research starts one shared IP cooldown of 30/60/90 +minutes, respectively; mode changes cannot bypass it. Prior tests that count +free member requests establish behavior before this change; they do not verify +current deductions. New rollout acceptance must check actual ledger debits from +the first request across web, CLI, and MCP search, no debit after backend failure, +and refusal before backend work when the balance is insufficient. See +[the current operator policy](COMMUNITY-ROLLOUT.md#default-quotas-and-public-api-compatibility). diff --git a/docs/COMMUNITY.md b/docs/COMMUNITY.md index e071864..8faf353 100644 --- a/docs/COMMUNITY.md +++ b/docs/COMMUNITY.md @@ -16,19 +16,22 @@ People can: - Follow topics across the web and MCP, check article coverage, and record requests for missing articles. Automatic article authoring is still in development. - View monthly credits and manage an optional paid subscription or subscriber top-up when live payments are configured. -Guests share **one successful Search, Research, or Answer request per minute per IP**. -Guest Answer is additionally capped at one per 5 minutes and Research at one per -30 minutes. Reading pages or checking the allowance is free. Invalid input and -failed backend requests do not consume the allowance. HTTP 429 includes -`Retry-After`, `retry_at`, and `retry_after_seconds`. People on a shared public IP -share this persistent, atomic guest allowance. **Contributions require login.** - -Members receive **60 shared free requests per minute plus 1,000 free credits each -UTC calendar month**, 1,000 new contributed URLs per rolling 24 hours, and 200 +Guests share one persistent cooldown per public IP. A successful **Search holds +it for 30 minutes, Answer for 60 minutes, or Research for 90 minutes**. The +cooldown applies across all three modes: after Answer, even Search must wait +60 minutes. Changing modes does not create another allowance. People sharing a +public IP also share this cooldown. Invalid input and failed backend requests +do not consume it; reading the app or checking the allowance is free. HTTP 429 +includes `Retry-After`, `retry_at`, and `retry_after_seconds`. +**Contributions require login.** + +Members receive **1,000 free credits each UTC calendar month**, +1,000 new contributed URLs per rolling 24 hours, and 200 saved searches. The monthly grant is applied once per account for the current month on authenticated use; it does not accumulate grants for inactive past -months. All unused credits carry over. Mode caps and credit costs are described -below. +months. All unused credits carry over. Every successful authenticated Search, +Answer, or Research request spends credits, including the first request. There +is no free per-minute member bypass. Mode caps and credit costs are described below. ## Start the services @@ -115,9 +118,9 @@ multiple rewards. Existing corpus URLs and rejected/unverified submissions do not earn credits. A submission acknowledgement is not a reward: the backend must confirm approved new content was indexed. -After the shared free 60 requests/minute, successful extra requests spend credits: +Every successful authenticated retrieval spends credits: -| Mode | Credits per extra request | Hard cap per account | +| Mode | Credits per successful request | Hard cap per account | | --- | --- | --- | | Search | 1 | 120/minute | | Answer | 2 | 20/minute | @@ -129,12 +132,18 @@ Credits cannot bypass hard caps. Mode limits persist across restarts and are shared by sessions and public endpoint aliases. Failed backend requests release reservations and refund debits. `GET /api/credits` returns the balance, current UTC month's free/earned/purchased/spent activity, and policy. MCP search uses this same -gateway ledger; the MCP service also has a separate daily tool-call cap. +gateway ledger; the MCP service also has a separate daily tool-call cap. Credits +are reserved atomically before retrieval and retained only for a successful +backend response. Insufficient credit rejects the request before backend work; +contribute approved new content, add credits when billing is available, or wait +for the next monthly grant. Reading a balance or managing saved requests does +not itself spend retrieval credits. ## Plans and payments -The **Free plan requires no subscription** and includes the monthly 1,000 credits -and 60 free requests/minute. An optional **$5/month subscription adds 50,000 credits +The **Free plan requires no subscription** and includes the monthly 1,000 credits. +Those credits pay for requests at the same 1/2/3 rates. An optional +**$5/month subscription adds 50,000 credits per paid month** and unlocks one-time **$5/50,000-credit top-ups**. Subscribers still receive the free monthly credits. Unused credits carry over; cancellation does not erase remaining earned or purchased credits. Refunds revoke the corresponding @@ -153,13 +162,17 @@ through the same portal policy as `/api/*`. These public aliases support GET wit portal. Unlisted native routes return 404 to prevent quota bypasses. The internal loopback engine remains available to trusted operators. `GET /api/limits` publishes current limits. Operators can configure -`-guest-interval`, `-member-free-rpm`, `-search-rpm`, `-answer-rpm`, and -`-research-per-10m` on the community command. A guest interval change preserves -the original request time instead of resetting all allowances. In-flight requests -reserve a mode slot; backend failures release it and refund charged credits. -The shared free member allowance also persists across restarts. Failed backend -requests release both free and mode reservations. At the defaults, Search-only -usage can consume 60 free requests and then 60 credit-funded requests per minute. +`-guest-interval`, `-search-rpm`, `-answer-rpm`, and `-research-per-10m` on +the community command. `-guest-interval` defaults to `30m`: successful Search, +Answer, and Research hold the shared guest cooldown for 1, 2, and 3 times that +base interval, respectively. The web app and CLI use the same server policy. `-member-free-rpm` is deprecated and ignored: it cannot +restore the old uncharged member allowance. A guest interval change preserves +the original request time instead of resetting allowances. In-flight requests +reserve a mode slot and, for authenticated retrieval, the mode's credit cost. +Failed backend requests release the mode reservation and refund credits. Mode +limits and balances persist across restarts. At the defaults, an account with +sufficient balance can make up to 120 Search requests/minute, spending one credit +for each successful response. ## CLI and CSV @@ -241,14 +254,14 @@ enabled. CLI clients may omit Origin. Login returns an HttpOnly session cookie. | `POST /api/logout` | Member | Revokes current session | | `GET /api/me` | Member | Profile and interests | | `PUT /api/interests` | Member | `{interests:[...]}`; completes onboarding, including an empty list | -| `GET /api/guest` | Public | Current IP's allowance and next available time | +| `GET /api/guest` | Public | Current IP's shared cooldown and next available time | | `GET /api/search?q=...` | Guest or member | Cosift `/search`, preserving backend defaults | | `GET /api/research?q=...` | Guest or member | Cosift `/research`; plan, synthesized answer and cited sources | | `GET /api/answer?q=...` | Guest or member | Cosift `/answer`; direct answer and cited sources | | `GET /api/saved` | Member | Own saved searches | | `POST /api/saved` | Member | `{query,mode}`; mode defaults to `search`; idempotent per account/query/mode | | `DELETE /api/saved/{id}` | Member | Removes an owned saved search | -| `GET /api/credits` | Member | Balance, monthly activity, weighted request costs, subscription state, top-up eligibility, and payment mode | +| `GET /api/credits` | Member | Balance, monthly activity, `all_authenticated_requests_metered:true`, weighted request costs, subscription state, top-up eligibility, and payment mode | | `POST /api/payments/checkout` | Member | `{kind:"subscription"\|"topup",idempotency_key}`; returns a hosted Stripe Checkout URL | | `POST /api/payments/portal` | Member | `{}`; returns an existing subscriber's restricted billing portal URL | | `POST /api/payments/webhook` | Stripe signature | Paid invoice/top-up fulfillment, subscription state, and refund reconciliation | diff --git a/docs/PRODUCTION-READINESS.md b/docs/PRODUCTION-READINESS.md index f1fcf23..56ace39 100644 --- a/docs/PRODUCTION-READINESS.md +++ b/docs/PRODUCTION-READINESS.md @@ -115,3 +115,18 @@ a second full-corpus instance on the current production host. Read-only inspection found the original engine active and the community service and self-updater inactive. The configuration and on-disk rollback artifacts remain separate from the reviewed candidate; this report authorizes no activation. + + +## Metering policy changed after this validation + +The recorded free-per-minute member allowance is historical. Current policy +charges every successful authenticated Search/Answer/Research request 1/2/3 +credits, including the first request, and grants each account 1,000 free credits +per UTC month. Guest limits also changed after the original validation: a +successful Search/Answer/Research starts one shared IP cooldown of 30/60/90 +minutes, respectively; mode changes cannot bypass it. Prior tests that count +free member requests establish behavior before this change; they do not verify +current deductions. New rollout acceptance must check actual ledger debits from +the first request across web, CLI, and MCP search, no debit after backend failure, +and refusal before backend work when the balance is insufficient. See +[the current operator policy](COMMUNITY-ROLLOUT.md#default-quotas-and-public-api-compatibility). diff --git a/docs/SHARED-ACCOUNTS-VALIDATION.md b/docs/SHARED-ACCOUNTS-VALIDATION.md index 161be88..d324976 100644 --- a/docs/SHARED-ACCOUNTS-VALIDATION.md +++ b/docs/SHARED-ACCOUNTS-VALIDATION.md @@ -108,3 +108,18 @@ The first full test run flagged the new operator-controlled auth/MCP HTTP client in the repository's outbound-client inventory. It now has an explicit documented exception, matching the existing operator-configured backend clients; contribution fetches retain their separate public-only DNS-pinned dialer. + + +## Metering policy changed after this validation + +The recorded free-per-minute member allowance is historical. Current policy +charges every successful authenticated Search/Answer/Research request 1/2/3 +credits, including the first request, and grants each account 1,000 free credits +per UTC month. Guest limits also changed after the original validation: a +successful Search/Answer/Research starts one shared IP cooldown of 30/60/90 +minutes, respectively; mode changes cannot bypass it. Prior tests that count +free member requests establish behavior before this change; they do not verify +current deductions. New rollout acceptance must check actual ledger debits from +the first request across web, CLI, and MCP search, no debit after backend failure, +and refusal before backend work when the balance is insufficient. See +[the current operator policy](COMMUNITY-ROLLOUT.md#default-quotas-and-public-api-compatibility). diff --git a/docs/SHARED-ACCOUNTS.md b/docs/SHARED-ACCOUNTS.md index 3c5375a..cd138ed 100644 --- a/docs/SHARED-ACCOUNTS.md +++ b/docs/SHARED-ACCOUNTS.md @@ -83,8 +83,10 @@ preserved. No new ranking change is included. - Search, Answer, Research, saved requests, URL/CSV contributions, local text / metadata / embedding contributions, moderation, credits and disabled-by-default Stripe purchases retain the existing community implementation. -- Web, CLI and MCP searches share each account's local allowance and - credit ledger. MCP retains its own upstream daily call cap (currently 1,000), +- Web, CLI, and MCP searches share one account ledger. Every successful + authenticated Search/Answer/Research costs 1/2/3 credits from its first request; + the free plan grants 1,000 credits each UTC month. There is no uncharged + per-minute member allowance. MCP retains its upstream daily call cap (currently 1,000), including topic tools. Credits do not bypass that cap or buy an article. Andrei's current MCP uses `NullArticleStore` with articles disabled. This work diff --git a/docs/STRIPE.md b/docs/STRIPE.md index 262679f..1df493f 100644 --- a/docs/STRIPE.md +++ b/docs/STRIPE.md @@ -1,12 +1,13 @@ # Stripe subscriptions and credit top-ups -Every account has a **Free plan: 1,000 credits per UTC calendar month plus 60 -shared free requests/minute**, with no subscription required. The optional +Every account has a **Free plan: 1,000 credits per UTC calendar month**, with no +subscription required. The optional **$5/month paid plan adds 50,000 credits per paid month** and permits **one-time $5/50,000-credit top-ups**. Subscribers still receive their free monthly credits. Unused free, earned, and purchased credits carry over. -After the free request allowance, Search costs 1 credit, Answer 2, and Research 3. +Every successful authenticated Search costs 1 credit, Answer 2, and Research 3. +This applies from the first request; there is no free per-minute member bypass. At this pack price, 1,000 paid requests cost $0.10 for Search, $0.20 for Answer, or $0.30 for Research. Credits do not bypass request caps. There are no automatic top-ups; the subscription itself renews monthly until canceled. @@ -98,6 +99,8 @@ See Stripe's [hosted Checkout guide](https://docs.stripe.com/checkout/quickstart - `subscription_plan`: server-owned `amount_cents`, `currency`, `credits`, and `interval` (`month`), alongside the existing one-time `credit_pack`. - `monthly_free_credits`, monthly activity, and `request_credit_costs`. +- `all_authenticated_requests_metered: true`; the compatibility + `free_requests_per_minute` field is zero. Top-up eligibility requires an active subscription and a paid current period; merely starting Checkout or holding a credit balance is insufficient. The @@ -155,7 +158,10 @@ Before activating live billing, use the isolated test environment to: Confirm the free monthly allowance and remaining balance survive cancellation. 4. Exercise partial/full refunds and duplicate deliveries; inspect the balance and Stripe delivery status. -5. Confirm public production still reports unavailable until live credentials, +5. Verify Search, Answer, and Research immediately deduct 1, 2, and 3 credits + respectively, including an account's first request. Backend failures must + refund their reservations; an insufficient balance must stop backend work. +6. Confirm public production still reports unavailable until live credentials, the correct live webhook, and the dedicated portal configuration are ready. Record actual hosted Checkout and webhook outcomes separately from local tests. diff --git a/integrations/cosift-mcp/search_contract.py b/integrations/cosift-mcp/search_contract.py index db9f0e7..57729ff 100644 --- a/integrations/cosift-mcp/search_contract.py +++ b/integrations/cosift-mcp/search_contract.py @@ -36,7 +36,7 @@ async def search(token): first, second = await asyncio.gather(search(TOKENS[0]), search(TOKENS[1])) assert not first.get("unavailable") and not second.get("unavailable"), (first, second) assert first["retriever"] == "bm25" and second["retriever"] == "bm25" - # A second request by Alice hits her free quota; Bob never used Alice's allowance. + # Alice's second request hits her hard cap; Bob's separate paid request succeeds. limited = await search(TOKENS[0]) assert limited.get("unavailable"), limited print("MCP → community → engine: identity isolation, quota enforcement, BM25/k contract passed") diff --git a/internal/community/credit_costs_test.go b/internal/community/credit_costs_test.go index 42ac6ed..2204930 100644 --- a/internal/community/credit_costs_test.go +++ b/internal/community/credit_costs_test.go @@ -42,9 +42,6 @@ func TestModeCreditCostsInsufficientBalanceAndFullRefund(t *testing.T) { w.Write([]byte(`{}`)) })) u, cookie := fundedCreditAccount(t, s, cost-1) - if _, err := s.db.Exec(`INSERT INTO retrieval_usage VALUES(?,'free',?,?)`, "member:"+u.ID, s.cfg.MemberFreeRPM, time.Now().Add(time.Minute).Unix()); err != nil { - t.Fatal(err) - } denied := request(t, s, "GET", "/api/"+mode+"?q=test", nil, cookie) expect(t, denied, 429) if calls != 0 || !strings.Contains(denied.Body.String(), "requires") { diff --git a/internal/community/credits.go b/internal/community/credits.go index 8dba9ed..8ac77df 100644 --- a/internal/community/credits.go +++ b/internal/community/credits.go @@ -49,7 +49,7 @@ FROM credit_ledger WHERE user_id=?`, start.Unix(), end.Unix(), start.Unix(), end problem(w, 503, "billing status unavailable") return } - out := map[string]any{"balance": balance, "monthly_free_credits": monthlyFreeCredits, "monthly": map[string]any{"month": start.Format("2006-01"), "starts_at": start.Format(time.RFC3339), "timezone": "UTC", "free": free, "earned": earned, "purchased": purchased, "spent": spent}, "free_requests_per_minute": s.cfg.MemberFreeRPM, "limits": s.limitPolicy(), "extra_request_cost": 1, "request_credit_costs": requestCreditCosts(), "verified_contribution_reward": contributionReward, "payments_enabled": s.paymentsEnabled(), "payment_mode": s.paymentMode(), "credit_pack": creditPack()} + out := map[string]any{"balance": balance, "monthly_free_credits": monthlyFreeCredits, "monthly": map[string]any{"month": start.Format("2006-01"), "starts_at": start.Format(time.RFC3339), "timezone": "UTC", "free": free, "earned": earned, "purchased": purchased, "spent": spent}, "free_requests_per_minute": 0, "all_authenticated_requests_metered": true, "limits": s.limitPolicy(), "extra_request_cost": 1, "request_credit_costs": requestCreditCosts(), "verified_contribution_reward": contributionReward, "payments_enabled": s.paymentsEnabled(), "payment_mode": s.paymentMode(), "credit_pack": creditPack()} for key, value := range billing { out[key] = value } @@ -77,10 +77,9 @@ SELECT ?,?,-?,'extra_request',? WHERE (SELECT COALESCE(sum(delta),0) FROM credit } n, _ := res.RowsAffected() if n == 0 { - w.Header().Set("Retry-After", "60") - message := fmt.Sprintf("free request limit reached; %s requires %d credits; contribute verified new webpages or try again in a minute", mode, cost) + message := fmt.Sprintf("insufficient credits: %s requires %d credits; contribute verified new webpages or use your next monthly grant", mode, cost) if s.paymentsEnabled() { - message = fmt.Sprintf("free request limit reached; %s requires %d credits; buy credits in the web app, contribute verified webpages, or try again in a minute", mode, cost) + message = fmt.Sprintf("insufficient credits: %s requires %d credits; add credits in Billing, contribute verified webpages, or use your next monthly grant", mode, cost) } problem(w, 429, message) return nil, false diff --git a/internal/community/credits_test.go b/internal/community/credits_test.go index f4c3eab..54f7991 100644 --- a/internal/community/credits_test.go +++ b/internal/community/credits_test.go @@ -38,10 +38,9 @@ func TestCreditsRewardOnceSpendAndRefund(t *testing.T) { if balance() != monthlyFreeCredits+10 { t.Fatal("duplicate reward") } - s.db.Exec(`INSERT INTO retrieval_usage VALUES(?,'free',?,?)`, "member:"+u.ID, s.cfg.MemberFreeRPM, time.Now().Add(time.Minute).Unix()) expect(t, request(t, s, "GET", "/api/search?q=test", nil, cookie), 200) if balance() != monthlyFreeCredits+9 { - t.Fatal("extra request not charged") + t.Fatal("first request not charged") } fail = true expect(t, request(t, s, "GET", "/api/search?q=test", nil, cookie), 502) diff --git a/internal/community/guest.go b/internal/community/guest.go index 60370a5..b529f4d 100644 --- a/internal/community/guest.go +++ b/internal/community/guest.go @@ -65,15 +65,22 @@ func (s *Server) guestStatus(w http.ResponseWriter, r *http.Request) { problem(w, 500, "could not check guest allowance") return } - respond(w, 200, map[string]any{"available": until <= time.Now().Unix(), "retry_at": until, "interval_seconds": int(s.cfg.GuestInterval.Seconds())}) + respond(w, 200, map[string]any{"available": until <= time.Now().Unix(), "retry_at": until, "interval_seconds": int(s.cfg.GuestInterval.Seconds()), "mode_intervals_seconds": s.guestIntervals()}) } // reserveGuest is atomic across concurrent requests and survives restarts. // Failure paths release this exact reservation; success commits the cooldown. -func (s *Server) reserveGuest(w http.ResponseWriter, r *http.Request) (finish func(bool), ok bool) { - key, token := s.guestKey(r), randomID() +func (s *Server) reserveGuest(w http.ResponseWriter, r *http.Request, mode string) (finish func(bool), ok bool) { + interval := s.guestIntervals()[mode] + if interval == 0 { + problem(w, 400, "mode must be search, answer or research") + return nil, false + } + // Carry the mode inside the opaque reservation so configuration migrations + // can preserve the original request time without adding a second quota table. + key, token := s.guestKey(r), mode+":"+randomID() now := time.Now().Unix() - until := now + int64(s.cfg.GuestInterval.Seconds()) + until := now + int64(interval) _, err := s.db.ExecContext(r.Context(), `DELETE FROM guest_usage WHERE expires_at<=?`, now) if err != nil { problem(w, 500, "guest allowance unavailable") diff --git a/internal/community/guest_weighted_test.go b/internal/community/guest_weighted_test.go new file mode 100644 index 0000000..f760c4a --- /dev/null +++ b/internal/community/guest_weighted_test.go @@ -0,0 +1,213 @@ +package community + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" +) + +func TestGuestWeightedCooldownSharesModesAliasesAndPersists(t *testing.T) { + for mode, cost := range requestCreditCosts() { + t.Run(mode, func(t *testing.T) { + calls := 0 + s := testServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++; _, _ = w.Write([]byte(`{}`)) })) + before := time.Now().Unix() + expect(t, request(t, s, "GET", "/api/"+mode+"?q=first", nil, nil), 200) + var until int64 + var reservation string + if err := s.db.QueryRow(`SELECT expires_at,reservation FROM guest_usage`).Scan(&until, &reservation); err != nil { + t.Fatal(err) + } + interval := int64(1800 * cost) + if until < before+interval || until > time.Now().Unix()+interval || !strings.HasPrefix(reservation, mode+":") { + t.Fatalf("incorrect %s reservation: %d %s", mode, until-before, reservation) + } + for _, next := range []string{"search", "answer", "research"} { + for _, prefix := range []string{"/", "/api/"} { + r := httptest.NewRequest("GET", prefix+next+"?q=blocked", nil) + r.Header.Set("X-Forwarded-For", "203.0.113.123") + w := httptest.NewRecorder() + s.ServeHTTP(w, r) + expect(t, w, 429) + var response struct { + RetryAt int64 `json:"retry_at"` + RetryAfter int64 `json:"retry_after_seconds"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil || response.RetryAt != until || (response.RetryAfter < until-time.Now().Unix() || response.RetryAfter > until-time.Now().Unix()+1) || w.Header().Get("Retry-After") != strconv.FormatInt(response.RetryAfter, 10) { + t.Fatal("incorrect retry guidance", w.Body.String(), err) + } + } + } + var status struct { + Available bool + RetryAt int64 `json:"retry_at"` + Intervals map[string]int `json:"mode_intervals_seconds"` + } + if err := json.Unmarshal(request(t, s, "GET", "/api/guest", nil, nil).Body.Bytes(), &status); err != nil || status.Available || status.RetryAt != until || status.Intervals["search"] != 1800 || status.Intervals["answer"] != 3600 || status.Intervals["research"] != 5400 { + t.Fatalf("wrong status %+v %v", status, err) + } + policy := s.limitPolicy()["guest"].(map[string]any) + if policy[mode].(modeLimit).WindowSeconds != int(interval) { + t.Fatal("wrong public mode interval") + } + reopened, err := Open(s.cfg) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + expect(t, request(t, reopened, "GET", "/search?q=restart", nil, nil), 429) + if calls != 1 { + t.Fatal("cooldown bypass reached backend", calls) + } + var ledger int + if err = s.db.QueryRow(`SELECT count(*) FROM credit_ledger`).Scan(&ledger); err != nil || ledger != 0 { + t.Fatal("guest created credit activity", err) + } + }) + } +} + +func TestGuestWeightedFailureReleasesOnlyItsOwnReservation(t *testing.T) { + for mode := range requestCreditCosts() { + t.Run(mode, func(t *testing.T) { + fail := true + s := testServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if fail { + w.WriteHeader(503) + return + } + _, _ = w.Write([]byte(`{}`)) + })) + expect(t, request(t, s, "GET", "/api/"+mode+"?q=failed", nil, nil), 502) + var rows int + if err := s.db.QueryRow(`SELECT count(*) FROM guest_usage`).Scan(&rows); err != nil || rows != 0 { + t.Fatal("failed guest retained cooldown", err) + } + fail = false + expect(t, request(t, s, "GET", "/"+mode+"?q=retry", nil, nil), 200) + }) + } + s := testServer(t, nil) + r := httptest.NewRequest("GET", "/search?q=test", nil) + old, ok := s.reserveGuest(httptest.NewRecorder(), r, "search") + if !ok { + t.Fatal("first reservation denied") + } + old(false) + next, ok := s.reserveGuest(httptest.NewRecorder(), r, "research") + if !ok { + t.Fatal("retry denied") + } + next(true) + old(false) + expect(t, request(t, s, "GET", "/api/answer?q=blocked", nil, nil), 429) +} + +func TestGuestWeightedPolicyMigrationPreservesOriginalRequestTime(t *testing.T) { + s := testServer(t, nil) + used := time.Now().Unix() - 10 + if _, err := s.db.Exec(`UPDATE settings SET value='60' WHERE key='guest_interval_seconds'`); err != nil { + t.Fatal(err) + } + for _, item := range []struct { + key, token string + weight int64 + }{{"legacy", "oldopaque", 1}, {"search", "search:opaque", 1}, {"answer", "answer:opaque", 2}, {"research", "research:opaque", 3}} { + if _, err := s.db.Exec(`INSERT INTO guest_usage(ip_hash,expires_at,reservation) VALUES(?,?,?)`, item.key, used+60*item.weight, item.token); err != nil { + t.Fatal(err) + } + } + for range 2 { + if err := s.migrateGuestInterval(); err != nil { + t.Fatal(err) + } + for _, item := range []struct { + key string + weight int64 + }{{"legacy", 1}, {"search", 1}, {"answer", 2}, {"research", 3}} { + var until int64 + if err := s.db.QueryRow(`SELECT expires_at FROM guest_usage WHERE ip_hash=?`, item.key).Scan(&until); err != nil || until != used+1800*item.weight { + t.Fatalf("%s migration changed request time: %d %v", item.key, until, err) + } + } + } +} + +func TestGuestLoweredIntervalMigratesModeCapsAndMatchesAvailability(t *testing.T) { + s := testServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{}`)) })) + expect(t, request(t, s, "GET", "/research?q=initial", nil, nil), 200) + // Move the successful research request four minutes into the past. Lowering + // the base from30minutes to1minute makes its new3-minute cooldown expire. + if _, err := s.db.Exec(`UPDATE guest_usage SET expires_at=expires_at-240`); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec(`UPDATE retrieval_usage SET expires_at=expires_at-240 WHERE identity LIKE 'guest:%'`); err != nil { + t.Fatal(err) + } + memberUntil := time.Now().Unix() + 600 + if _, err := s.db.Exec(`INSERT INTO retrieval_usage(identity,mode,count,expires_at) VALUES('member:unchanged','research',1,?)`, memberUntil); err != nil { + t.Fatal(err) + } + s.cfg.GuestInterval = time.Minute + for range 2 { + if err := s.migrateGuestInterval(); err != nil { + t.Fatal(err) + } + } + var sharedUntil, modeUntil, stillMember int64 + if err := s.db.QueryRow(`SELECT expires_at FROM guest_usage`).Scan(&sharedUntil); err != nil { + t.Fatal(err) + } + if err := s.db.QueryRow(`SELECT expires_at FROM retrieval_usage WHERE identity LIKE 'guest:%'`).Scan(&modeUntil); err != nil { + t.Fatal(err) + } + if err := s.db.QueryRow(`SELECT expires_at FROM retrieval_usage WHERE identity='member:unchanged'`).Scan(&stillMember); err != nil { + t.Fatal(err) + } + if sharedUntil != modeUntil || modeUntil > time.Now().Unix() || stillMember != memberUntil { + t.Fatalf("migration mismatch shared=%d mode=%d member=%d", sharedUntil, modeUntil, stillMember) + } + var status struct{ Available bool } + if err := json.Unmarshal(request(t, s, "GET", "/api/guest", nil, nil).Body.Bytes(), &status); err != nil || !status.Available { + t.Fatal("expired cooldown not available", err) + } + expect(t, request(t, s, "GET", "/api/research?q=after-migration", nil, nil), 200) +} + +func TestGuestLegacyModeExpiryMigrationMatchesSharedReservation(t *testing.T) { + s := testServer(t, nil) + used := time.Now().Unix() - 10 + if _, err := s.db.Exec(`UPDATE settings SET value='60' WHERE key='guest_interval_seconds'`); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec(`INSERT INTO guest_usage(ip_hash,expires_at,reservation) VALUES('legacy',?,'oldopaque')`, used+60); err != nil { + t.Fatal(err) + } + for mode, window := range map[string]int64{"search": 60, "answer": 300, "research": 1800} { + if _, err := s.db.Exec(`INSERT INTO retrieval_usage(identity,mode,count,expires_at) VALUES('guest:legacy',?,1,?)`, mode, used+window); err != nil { + t.Fatal(err) + } + } + if _, err := s.db.Exec(`INSERT INTO retrieval_usage(identity,mode,count,expires_at) VALUES('guest:orphan','research',1,?)`, used+1800); err != nil { + t.Fatal(err) + } + for range 2 { + if err := s.migrateGuestInterval(); err != nil { + t.Fatal(err) + } + for mode := range requestCreditCosts() { + var until int64 + if err := s.db.QueryRow(`SELECT expires_at FROM retrieval_usage WHERE identity='guest:legacy' AND mode=?`, mode).Scan(&until); err != nil || until != used+1800 { + t.Fatalf("legacy %s expiry=%d want%d: %v", mode, until, used+1800, err) + } + } + var orphanUntil int64 + if err := s.db.QueryRow(`SELECT expires_at FROM retrieval_usage WHERE identity='guest:orphan'`).Scan(&orphanUntil); err != nil || orphanUntil != 0 { + t.Fatal("orphan quota remained active", orphanUntil, err) + } + } +} diff --git a/internal/community/limits.go b/internal/community/limits.go index 2ef62ce..c72bfbd 100644 --- a/internal/community/limits.go +++ b/internal/community/limits.go @@ -12,10 +12,7 @@ import ( func (c *Config) defaultLimits() error { if c.GuestInterval == 0 { - c.GuestInterval = time.Minute - } - if c.MemberFreeRPM == 0 { - c.MemberFreeRPM = 60 + c.GuestInterval = 30 * time.Minute } if c.SearchRPM == 0 { c.SearchRPM = 120 @@ -29,7 +26,7 @@ func (c *Config) defaultLimits() error { if c.GuestInterval < time.Second || c.GuestInterval > 24*time.Hour || c.GuestInterval%time.Second != 0 { return fmt.Errorf("guest interval must be whole seconds between 1s and 24h") } - for _, n := range []int{c.MemberFreeRPM, c.SearchRPM, c.AnswerRPM, c.ResearchPer10Min} { + for _, n := range []int{c.SearchRPM, c.AnswerRPM, c.ResearchPer10Min} { if n < 1 || n > 10000 { return fmt.Errorf("request limits must be between 1 and 10000") } @@ -38,13 +35,25 @@ func (c *Config) defaultLimits() error { } func (s *Server) limitPolicy() map[string]any { + guest := s.guestIntervals() return map[string]any{ - "guest_interval_seconds": int(s.cfg.GuestInterval.Seconds()), - "guest": map[string]any{"search": modeLimit{1, int(s.cfg.GuestInterval.Seconds())}, "answer": modeLimit{1, max(int(s.cfg.GuestInterval.Seconds()), 300)}, "research": modeLimit{1, max(int(s.cfg.GuestInterval.Seconds()), 1800)}}, - "member": map[string]any{"search": modeLimit{s.cfg.SearchRPM, 60}, "answer": modeLimit{s.cfg.AnswerRPM, 60}, "research": modeLimit{s.cfg.ResearchPer10Min, 600}}, - "member_free_requests_per_minute": s.cfg.MemberFreeRPM, - "credits_bypass_caps": false, + "guest_interval_seconds": int(s.cfg.GuestInterval.Seconds()), + "guest": map[string]any{"search": modeLimit{1, guest["search"]}, "answer": modeLimit{1, guest["answer"]}, "research": modeLimit{1, guest["research"]}}, + "member": map[string]any{"search": modeLimit{s.cfg.SearchRPM, 60}, "answer": modeLimit{s.cfg.AnswerRPM, 60}, "research": modeLimit{s.cfg.ResearchPer10Min, 600}}, + "member_free_requests_per_minute": 0, + "all_authenticated_requests_metered": true, + "request_credit_costs": requestCreditCosts(), + "credits_bypass_caps": false, + } +} + +func (s *Server) guestIntervals() map[string]int { + base := int(s.cfg.GuestInterval.Seconds()) + intervals := make(map[string]int, 3) + for mode, cost := range requestCreditCosts() { + intervals[mode] = base * cost } + return intervals } type modeLimit struct { @@ -66,13 +75,7 @@ func (s *Server) allowRetrieval(w http.ResponseWriter, r *http.Request, u User, } if u.ID == "" { identity = "guest:" + s.guestKey(r) - limit, window = 1, int64(s.cfg.GuestInterval.Seconds()) - switch mode { - case "answer": - window = max(window, 300) - case "research": - window = max(window, 1800) - } + limit, window = 1, int64(s.guestIntervals()[mode]) } now := time.Now().Unix() var until int64 @@ -121,7 +124,15 @@ func (s *Server) migrateGuestInterval() error { } next := int64(s.cfg.GuestInterval.Seconds()) if old != next { - if _, err = tx.Exec(`UPDATE guest_usage SET expires_at=expires_at-?+?`, old, next); err != nil { + // Legacy reservations had no mode prefix and used one uniform interval. + // New reservations retain their weight when an operator changes the base. + if _, err = tx.Exec(`UPDATE guest_usage SET expires_at=expires_at+(?-?)*CASE WHEN reservation LIKE 'research:%' THEN 3 WHEN reservation LIKE 'answer:%' THEN 2 ELSE 1 END`, next, old); err != nil { + return err + } + // The shared reservation is authoritative across guest modes. Align mode + // caps with that expiry, including legacy uniform reservations. Orphaned + // legacy mode rows expire instead of contradicting /api/guest status. + if _, err = tx.Exec(`UPDATE retrieval_usage SET expires_at=COALESCE((SELECT expires_at FROM guest_usage WHERE ip_hash=substr(retrieval_usage.identity,7)),0) WHERE identity LIKE 'guest:%' AND mode IN ('search','answer','research')`); err != nil { return err } } @@ -130,30 +141,3 @@ func (s *Server) migrateGuestInterval() error { } return tx.Commit() } - -// reserveFree keeps the shared free allowance in the same durable database as -// mode caps and credits. Restarts cannot turn paid requests into free requests. -func (s *Server) reserveFree(r *http.Request, u User) (func(bool), bool, error) { - now := time.Now().Unix() - identity := "member:" + u.ID - var until int64 - err := s.db.QueryRowContext(r.Context(), `INSERT INTO retrieval_usage(identity,mode,count,expires_at) VALUES(?,'free',1,?) - ON CONFLICT(identity,mode) DO UPDATE SET - count=CASE WHEN retrieval_usage.expires_at<=? THEN 1 ELSE retrieval_usage.count+1 END, - expires_at=CASE WHEN retrieval_usage.expires_at<=? THEN excluded.expires_at ELSE retrieval_usage.expires_at END - WHERE retrieval_usage.expires_at<=? OR retrieval_usage.count RETURNING expires_at`, identity, now+60, now, now, now, s.cfg.MemberFreeRPM).Scan(&until) - if errors.Is(err, sql.ErrNoRows) { - return nil, false, nil - } - if err != nil { - return nil, false, err - } - return func(success bool) { - if success { - return - } - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - _, _ = s.db.ExecContext(ctx, `UPDATE retrieval_usage SET count=count-1 WHERE identity=? AND mode='free' AND expires_at=? AND count>0`, identity, until) - }, true, nil -} diff --git a/internal/community/limits_test.go b/internal/community/limits_test.go index 85868b7..bd64bcb 100644 --- a/internal/community/limits_test.go +++ b/internal/community/limits_test.go @@ -39,7 +39,7 @@ func TestModeCapsShareAliasesAndCannotSpendPastCap(t *testing.T) { } var balance int s.db.QueryRow(`SELECT SUM(delta) FROM credit_ledger WHERE user_id=?`, u.ID).Scan(&balance) - if balance != monthlyFreeCredits+94 { + if balance != monthlyFreeCredits+93 { t.Fatalf("charged rejected request: %d", balance) } // A new process must not grant a new expensive Research allowance. @@ -98,6 +98,7 @@ func TestGuestResearchSeparateFromSharedAllowance(t *testing.T) { func TestGuestPolicyMigrationPreservesRequestTime(t *testing.T) { s := testServer(t, nil) + s.cfg.GuestInterval = time.Minute usedAt := time.Now().Unix() - 10 s.db.Exec(`DELETE FROM settings WHERE key='guest_interval_seconds'`) s.db.Exec(`INSERT INTO guest_usage VALUES('legacy',?,'reservation')`, usedAt+1800) @@ -113,7 +114,7 @@ func TestGuestPolicyMigrationPreservesRequestTime(t *testing.T) { } } -func TestDefaultSearchCreditsAndFreeAllowanceSurviveRestart(t *testing.T) { +func TestDefaultSearchMeteringAndCapsSurviveRestart(t *testing.T) { s := testServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"hits":[]}`)) })) cookie := account(t, s, "default-credits@example.com") var u User @@ -124,7 +125,7 @@ func TestDefaultSearchCreditsAndFreeAllowanceSurviveRestart(t *testing.T) { if _, err := s.db.Exec(`INSERT INTO credit_ledger VALUES('seed-default',?,100,'test',0)`, u.ID); err != nil { t.Fatal(err) } - // The 61st request must work once credits are available under the defaults. + // Every request spends credits, including the first60 and requests after restart. expect(t, request(t, s, "GET", "/api/search?q=test", nil, cookie), 200) reopened, err := Open(s.cfg) if err != nil { @@ -134,20 +135,20 @@ func TestDefaultSearchCreditsAndFreeAllowanceSurviveRestart(t *testing.T) { expect(t, request(t, reopened, "GET", "/search?q=test", nil, cookie), 200) var balance int s.db.QueryRow(`SELECT SUM(delta) FROM credit_ledger WHERE user_id=?`, u.ID).Scan(&balance) - if balance != monthlyFreeCredits+98 { - t.Fatalf("restart reset free allowance: balance=%d want1098", balance) + if balance != monthlyFreeCredits+38 { + t.Fatalf("restart changed metering: balance=%d want1038", balance) } for range 58 { expect(t, request(t, reopened, "GET", "/api/search?q=test", nil, cookie), 200) } expect(t, request(t, reopened, "GET", "/search?q=test", nil, cookie), 429) s.db.QueryRow(`SELECT SUM(delta) FROM credit_ledger WHERE user_id=?`, u.ID).Scan(&balance) - if balance != monthlyFreeCredits+40 { + if balance != monthlyFreeCredits-20 { t.Fatalf("wrong charge total: %d", balance) } } -func TestFailedRetrievalRefundsFreeAllowance(t *testing.T) { +func TestFailedRetrievalRefundsMeteredCredits(t *testing.T) { fail := true s := testServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if fail { @@ -163,7 +164,7 @@ func TestFailedRetrievalRefundsFreeAllowance(t *testing.T) { expect(t, request(t, s, "GET", "/api/search?q=test", nil, cookie), 200) expect(t, request(t, s, "GET", "/api/search?q=test", nil, cookie), 200) var debits int - if err := s.db.QueryRow(`SELECT count(*) FROM credit_ledger WHERE reason='extra_request'`).Scan(&debits); err != nil || debits != 1 { + if err := s.db.QueryRow(`SELECT count(*) FROM credit_ledger WHERE reason='extra_request'`).Scan(&debits); err != nil || debits != 2 { t.Fatalf("failed request was charged: %d %v", debits, err) } } diff --git a/internal/community/metered_requests_test.go b/internal/community/metered_requests_test.go new file mode 100644 index 0000000..55e5565 --- /dev/null +++ b/internal/community/metered_requests_test.go @@ -0,0 +1,135 @@ +package community + +import ( + "context" + "encoding/json" + "net/http" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestFirstAuthenticatedRequestIsMeteredAcrossModesAndAliases(t *testing.T) { + var calls atomic.Int32 + fail := false + s := testServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if fail { + w.WriteHeader(503) + return + } + _, _ = w.Write([]byte(`{}`)) + })) + s.cfg.Shared = &fakeShared{} + s.cfg.MemberFreeRPM = 10000 // Legacy configuration must never restore free requests. + balance := func() int { + var n int + if err := s.db.QueryRow(`SELECT COALESCE(sum(delta),0) FROM credit_ledger`).Scan(&n); err != nil { + t.Fatal(err) + } + return n + } + expect(t, bearerRequest(s, "/api/research?q=first", sharedTestToken), 200) + if balance() != 997 { + t.Fatalf("first Research balance=%d want997", balance()) + } + want := 997 + for _, item := range []struct { + path string + cost int + }{{"/research", 3}, {"/api/answer", 2}, {"/answer", 2}, {"/api/search", 1}, {"/search", 1}} { + expect(t, bearerRequest(s, item.path+"?q=test", sharedTestToken), 200) + want -= item.cost + if balance() != want { + t.Fatalf("%s balance=%d want%d", item.path, balance(), want) + } + } + fail = true + expect(t, bearerRequest(s, "/api/research?q=failure", sharedTestToken), 502) + if balance() != want { + t.Fatal("failed Research retained3 credits") + } + for _, path := range []string{"/api/credits", "/api/limits"} { + w := bearerRequest(s, path, sharedTestToken) + expect(t, w, 200) + var policy map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &policy); err != nil { + t.Fatal(err) + } + if policy["all_authenticated_requests_metered"] != true { + t.Fatal("API did not advertise metering", w.Body.String()) + } + key := "free_requests_per_minute" + if path == "/api/limits" { + key = "member_free_requests_per_minute" + } + if policy[key] != float64(0) { + t.Fatal("API advertised a free bypass", w.Body.String()) + } + } + var freeBuckets int + if err := s.db.QueryRow(`SELECT count(*) FROM retrieval_usage WHERE mode='free'`).Scan(&freeBuckets); err != nil || freeBuckets != 0 { + t.Fatal("legacy free allowance was used", err) + } + before := balance() + fail = false + expect(t, bearerRequest(s, "/api/search?q=guest", ""), 200) + if balance() != before { + t.Fatal("guest request charged an account") + } + if calls.Load() != 8 { + t.Fatal("unexpected backend calls", calls.Load()) + } +} + +func TestAuthenticatedConcurrentResearchCannotOverdrawOrBecomeGuest(t *testing.T) { + var calls atomic.Int32 + s := testServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1); _, _ = w.Write([]byte(`{}`)) })) + f := &fakeShared{} + s.cfg.Shared = f + s.cfg.ResearchPer10Min = 100 + s.cfg.MemberFreeRPM = 10000 + identity, err := f.Verify(context.Background(), sharedTestToken) + if err != nil { + t.Fatal(err) + } + u, err := s.sharedUser(context.Background(), identity) + if err != nil { + t.Fatal(err) + } + if err = s.grantMonthlyCredits(context.Background(), u.ID, time.Now()); err != nil { + t.Fatal(err) + } + if _, err = s.db.Exec(`INSERT INTO credit_ledger(id,user_id,delta,reason,created_at) VALUES('concurrent-research-budget',?,?,'fixture',0)`, u.ID, 5-monthlyFreeCredits); err != nil { + t.Fatal(err) + } + var wins, blocked, unexpected atomic.Int32 + var wg sync.WaitGroup + for range 16 { + wg.Add(1) + go func() { + defer wg.Done() + w := bearerRequest(s, "/research?q=concurrent", sharedTestToken) + switch w.Code { + case 200: + wins.Add(1) + case 429: + blocked.Add(1) + default: + unexpected.Add(1) + } + }() + } + wg.Wait() + if wins.Load() != 1 || blocked.Load() != 15 || unexpected.Load() != 0 || calls.Load() != 1 { + t.Fatalf("wins=%d blocked=%d unexpected=%d engine=%d", wins.Load(), blocked.Load(), unexpected.Load(), calls.Load()) + } + var balance, guests int + if err = s.db.QueryRow(`SELECT sum(delta) FROM credit_ledger WHERE user_id=?`, u.ID).Scan(&balance); err != nil || balance != 2 { + t.Fatal("concurrent debit overdraw", balance, err) + } + if err = s.db.QueryRow(`SELECT count(*) FROM guest_usage`).Scan(&guests); err != nil || guests != 0 { + t.Fatal("authenticated request fell back to guest", guests, err) + } +} diff --git a/internal/community/server.go b/internal/community/server.go index 8e271a1..21688eb 100644 --- a/internal/community/server.go +++ b/internal/community/server.go @@ -38,13 +38,14 @@ const dailyContributionLimit = 1000 type Config struct { Shared sharedaccount.Provider + SharedPasswordEnabled bool // Enable only after the upstream password service is deployed. DataDir string Backend string PublicURL string AdminToken string // Only used for crawl-enqueue, never forwarded with searches. TrustedProxies []string GuestInterval time.Duration - MemberFreeRPM int + MemberFreeRPM int // Deprecated and ignored: all authenticated retrievals cost credits. SearchRPM int AnswerRPM int ResearchPer10Min int @@ -133,10 +134,11 @@ func Open(cfg Config) (*Server, error) { } mux := http.NewServeMux() mux.HandleFunc("GET /api/auth/config", func(w http.ResponseWriter, r *http.Request) { - respond(w, 200, map[string]bool{"shared": s.cfg.Shared != nil}) + respond(w, 200, map[string]bool{"shared": s.cfg.Shared != nil, "supports_password": s.sharedPasswordProvider() != nil}) }) mux.HandleFunc("POST /api/auth/start", s.sharedStart) mux.HandleFunc("POST /api/auth/verify", s.sharedFinish) + mux.HandleFunc("POST /api/auth/password", s.sharedPassword) mux.HandleFunc("POST /api/shared", s.auth(s.sharedTool)) mux.HandleFunc("GET /{$}", s.asset("index.html", "text/html; charset=utf-8")) mux.HandleFunc("GET /login", s.asset("index.html", "text/html; charset=utf-8")) @@ -475,31 +477,23 @@ func (s *Server) retrieve(w http.ResponseWriter, r *http.Request, u User, mode s } completed := false if u.ID == "" { - finish, ok := s.reserveGuest(w, r) + finish, ok := s.reserveGuest(w, r, mode) if !ok { return } defer func() { finish(completed) }() } - // Hard mode caps apply before free allowance or credit charging. + // Hard mode caps apply before charging every authenticated request. finishMode, ok := s.allowRetrieval(w, r, u, mode) if !ok { return } defer func() { finishMode(completed) }() if u.ID != "" { - finish, free, err := s.reserveFree(r, u) - if err != nil { - problem(w, 503, "request allowance unavailable") + finish, ok := s.reserveCredit(w, r, u, mode) + if !ok { return } - if !free { - var ok bool - finish, ok = s.reserveCredit(w, r, u, mode) - if !ok { - return - } - } defer func() { finish(completed) }() } diff --git a/internal/community/shared.go b/internal/community/shared.go index d8b951a..2a51728 100644 --- a/internal/community/shared.go +++ b/internal/community/shared.go @@ -195,20 +195,95 @@ func (s *Server) sharedFinish(w http.ResponseWriter, r *http.Request) { return } var in struct { - RequestID string `json:"request_id"` - Code string `json:"code"` + RequestID string `json:"request_id"` + Code string `json:"code"` + Password *string `json:"password"` } if decode(r, &in) != nil || len(in.RequestID) != 26 || len(in.Code) != 6 || strings.Trim(in.Code, "0123456789") != "" { sharedProblem(w, sharedaccount.ErrInvalid) return } - issued, err := s.cfg.Shared.Finish(sharedaccount.WithClientIP(r.Context(), s.clientIP(r)), in.RequestID, in.Code) + ctx := sharedaccount.WithClientIP(r.Context(), s.clientIP(r)) + var issued sharedaccount.Issued + var err error + if in.Password != nil { + provider := s.sharedPasswordProvider() + if provider == nil { + problem(w, 404, "shared password login is not enabled") + return + } + if len(*in.Password) < 12 || len(*in.Password) > 256 { + problem(w, 400, "password must contain 12–256 UTF-8 bytes") + return + } + issued, err = provider.FinishPassword(ctx, in.RequestID, in.Code, *in.Password) + } else { + issued, err = s.cfg.Shared.Finish(ctx, in.RequestID, in.Code) + } if err != nil { sharedProblem(w, err) return } - identity, err := s.cfg.Shared.Verify(r.Context(), issued.Token) - if err == nil && identity.UID != issued.UID { + s.sharedIssued(w, r, issued, "") +} + +func (s *Server) sharedPasswordProvider() sharedaccount.PasswordProvider { + if !s.cfg.SharedPasswordEnabled || s.cfg.Shared == nil { + return nil + } + provider, _ := s.cfg.Shared.(sharedaccount.PasswordProvider) + return provider +} + +func sharedPasswordProblem(w http.ResponseWriter, err error) { + if errors.Is(err, sharedaccount.ErrUnauthorized) || errors.Is(err, sharedaccount.ErrBanned) { + problem(w, 401, "invalid email or password") + return + } + sharedProblem(w, err) +} + +func (s *Server) sharedPassword(w http.ResponseWriter, r *http.Request) { + provider := s.sharedPasswordProvider() + if provider == nil { + problem(w, 404, "shared password login is not enabled") + return + } + if !s.allow("shared-password:"+s.clientIP(r), 10, time.Minute) { + sharedProblem(w, sharedaccount.ErrLimited) + return + } + var in struct { + Email string `json:"email"` + Password string `json:"password"` + } + if decode(r, &in) != nil { + sharedProblem(w, sharedaccount.ErrInvalid) + return + } + in.Email = strings.ToLower(strings.TrimSpace(in.Email)) + a, err := mail.ParseAddress(in.Email) + if err != nil || a.Address != in.Email || len(in.Email) > 254 || len(in.Password) < 12 || len(in.Password) > 256 { + sharedPasswordProblem(w, sharedaccount.ErrUnauthorized) + return + } + issued, err := provider.Password(sharedaccount.WithClientIP(r.Context(), s.clientIP(r)), in.Email, in.Password) + if err != nil { + sharedPasswordProblem(w, err) + return + } + s.sharedIssued(w, r, issued, in.Email) +} + +// Both entry points establish the same verified UID and canonical token cookie. +// Password sign-in also binds the verified email to the submitted account. +func (s *Server) sharedIssued(w http.ResponseWriter, r *http.Request, issued sharedaccount.Issued, email string) { + _, err := sharedaccount.Parse(issued.Token) + var identity sharedaccount.Identity + if err == nil { + identity, err = s.cfg.Shared.Verify(r.Context(), issued.Token) + } + if err == nil && (identity.UID != issued.UID || email != "" && identity.Email != email) { err = sharedaccount.ErrUnauthorized } var u User @@ -219,7 +294,11 @@ func (s *Server) sharedFinish(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = s.cfg.Shared.Revoke(ctx, issued.Token) - sharedProblem(w, err) + if email != "" { + sharedPasswordProblem(w, err) + } else { + sharedProblem(w, err) + } return } http.SetCookie(w, s.sharedCookie(issued.Token, int(sessionAge.Seconds()))) diff --git a/internal/community/shared_handlers_test.go b/internal/community/shared_handlers_test.go index fbd466e..c51e08b 100644 --- a/internal/community/shared_handlers_test.go +++ b/internal/community/shared_handlers_test.go @@ -76,7 +76,7 @@ func TestSharedLoginDisabledAndAdvertised(t *testing.T) { s := testServer(t, nil) w := request(t, s, "GET", "/api/auth/config", nil, nil) expect(t, w, 200) - if strings.TrimSpace(w.Body.String()) != `{"shared":false}` { + if strings.TrimSpace(w.Body.String()) != `{"shared":false,"supports_password":false}` { t.Fatal(w.Body.String()) } for _, path := range []string{"/api/auth/start", "/api/auth/verify"} { @@ -89,7 +89,7 @@ func TestSharedLoginDisabledAndAdvertised(t *testing.T) { s.cfg.Shared = &fakeShared{} w = request(t, s, "GET", "/api/auth/config", nil, nil) expect(t, w, 200) - if strings.TrimSpace(w.Body.String()) != `{"shared":true}` { + if strings.TrimSpace(w.Body.String()) != `{"shared":true,"supports_password":false}` { t.Fatal(w.Body.String()) } } diff --git a/internal/community/shared_password_test.go b/internal/community/shared_password_test.go new file mode 100644 index 0000000..e760406 --- /dev/null +++ b/internal/community/shared_password_test.go @@ -0,0 +1,227 @@ +package community + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/pilot-protocol/cosift/internal/sharedaccount" +) + +type passwordShared struct { + scriptedShared + passwordFn func(context.Context, string, string) (sharedaccount.Issued, error) + enrollFn func(context.Context, string, string, string) (sharedaccount.Issued, error) +} + +func (p *passwordShared) Password(ctx context.Context, email, password string) (sharedaccount.Issued, error) { + if p.passwordFn != nil { + return p.passwordFn(ctx, email, password) + } + return p.Finish(ctx, "", "") +} +func (p *passwordShared) FinishPassword(ctx context.Context, id, code, password string) (sharedaccount.Issued, error) { + if p.enrollFn != nil { + return p.enrollFn(ctx, id, code, password) + } + return p.Finish(ctx, id, code) +} +func passwordBody(email, password string) string { + body, _ := json.Marshal(map[string]string{"email": email, "password": password}) + return string(body) +} +func TestSharedPasswordCapabilityRequiresExplicitReadyProvider(t *testing.T) { + for _, tc := range []struct { + name string + provider sharedaccount.Provider + enabled, want bool + }{ + {"local", nil, true, false}, {"old OTP provider", &fakeShared{}, true, false}, + {"new provider default off", &passwordShared{}, false, false}, {"ready", &passwordShared{}, true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + s := testServer(t, nil) + s.cfg.Shared = tc.provider + s.cfg.SharedPasswordEnabled = tc.enabled + w := request(t, s, "GET", "/api/auth/config", nil, nil) + expect(t, w, 200) + var cfg map[string]bool + if json.Unmarshal(w.Body.Bytes(), &cfg) != nil || cfg["supports_password"] != tc.want { + t.Fatal(w.Body.String()) + } + if !tc.want { + expect(t, sharedJSON(s, "/api/auth/password", passwordBody("shared@example.com", "password-valid-123"), "", ""), 404) + } + if tc.provider != nil && !tc.want { + expect(t, sharedJSON(s, "/api/auth/verify", `{"request_id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","code":"123456","password":"password-valid-123"}`, "", ""), 404) + expect(t, sharedJSON(s, "/api/auth/verify", `{"request_id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","code":"123456"}`, "", ""), 200) + } + }) + } +} +func TestSharedPasswordUsesExistingIdentityAndNeverStoresPassword(t *testing.T) { + s := testServer(t, nil) + s.cfg.PublicURL = "https://community.example.com" + s.cfg.SharedPasswordEnabled = true + p := &passwordShared{passwordFn: func(_ context.Context, email, password string) (sharedaccount.Issued, error) { + if email != "shared@example.com" || password != " password-valid-123 " { + t.Fatal("credentials changed before forwarding") + } + return sharedaccount.Issued{Token: sharedTestToken, UID: "0123456789abcdef"}, nil + }} + s.cfg.Shared = p + u, err := s.sharedUser(context.Background(), sharedaccount.Identity{UID: "0123456789abcdef", Email: "shared@example.com"}) + if err != nil { + t.Fatal(err) + } + if _, err = s.db.Exec(`UPDATE users SET onboarded=1,interests='["engineering"]' WHERE id=?`, u.ID); err != nil { + t.Fatal(err) + } + if _, err = s.db.Exec(`INSERT INTO credit_ledger(id,user_id,delta,reason,created_at) VALUES('password-preservation',?,17,'fixture',0)`, u.ID); err != nil { + t.Fatal(err) + } + w := sharedJSON(s, "/api/auth/password", passwordBody(" SHARED@Example.com ", " password-valid-123 "), "", "") + expect(t, w, 200) + var got User + if err = json.Unmarshal(w.Body.Bytes(), &got); err != nil || got.ID != u.ID || !got.Onboarded || len(got.Interests) != 1 { + t.Fatalf("identity changed: %+v %v", got, err) + } + if strings.Contains(w.Body.String(), sharedTestToken) || strings.Contains(w.Body.String(), "password-valid") { + t.Fatal("secret exposed") + } + cookies := w.Result().Cookies() + if len(cookies) != 1 || cookies[0].Value != sharedTestToken || !cookies[0].Secure || !cookies[0].HttpOnly || cookies[0].SameSite != http.SameSiteLaxMode { + t.Fatal("invalid shared cookie") + } + var users, sessions, balance int + var hash string + if err = s.db.QueryRow(`SELECT count(*),password_hash FROM users`).Scan(&users, &hash); err != nil || users != 1 || hash != "" { + t.Fatal("local account/password created", err) + } + if err = s.db.QueryRow(`SELECT count(*) FROM sessions`).Scan(&sessions); err != nil || sessions != 0 { + t.Fatal("local session created", err) + } + if err = s.db.QueryRow(`SELECT sum(delta) FROM credit_ledger WHERE user_id=?`, u.ID).Scan(&balance); err != nil || balance != 17 { + t.Fatal("ledger changed", err) + } + expect(t, bearerRequest(s, "/api/me", sharedTestToken), 200) + p.fakeShared.revoked = true + expect(t, bearerRequest(s, "/api/me", sharedTestToken), 401) +} +func TestSharedPasswordGenericFailuresAndIssuedTokenRevocation(t *testing.T) { + for _, tc := range []struct { + name string + issueErr, verifyErr error + identity *sharedaccount.Identity + status int + revoke bool + }{ + {name: "wrong or unknown", issueErr: sharedaccount.ErrUnauthorized, status: 401}, + {name: "banned", issueErr: sharedaccount.ErrBanned, status: 401}, + {name: "infrastructure", issueErr: errors.New("private upstream information"), status: 503}, + {name: "rate limit", issueErr: sharedaccount.ErrLimited, status: 429}, + {name: "revoked before use", verifyErr: sharedaccount.ErrUnauthorized, status: 401, revoke: true}, + {name: "banned before use", verifyErr: sharedaccount.ErrBanned, status: 401, revoke: true}, + {name: "UID mismatch", identity: &sharedaccount.Identity{UID: "fedcba9876543210", Email: "shared@example.com"}, status: 401, revoke: true}, + {name: "email mismatch", identity: &sharedaccount.Identity{UID: "0123456789abcdef", Email: "other@example.com"}, status: 401, revoke: true}, + {name: "verification unavailable", verifyErr: sharedaccount.ErrUnavailable, status: 503, revoke: true}, + } { + t.Run(tc.name, func(t *testing.T) { + s := testServer(t, nil) + s.cfg.SharedPasswordEnabled = true + revoked := false + p := &passwordShared{passwordFn: func(context.Context, string, string) (sharedaccount.Issued, error) { + return sharedaccount.Issued{Token: sharedTestToken, UID: "0123456789abcdef"}, tc.issueErr + }} + p.verifyFn = func(context.Context, string) (sharedaccount.Identity, error) { + if tc.identity != nil { + return *tc.identity, tc.verifyErr + } + return sharedaccount.Identity{UID: "0123456789abcdef", Email: "shared@example.com"}, tc.verifyErr + } + p.revokeFn = func(_ context.Context, token string) error { + revoked = true + if token != sharedTestToken { + t.Error("wrong revoked token") + } + return nil + } + s.cfg.Shared = p + w := sharedJSON(s, "/api/auth/password", passwordBody("shared@example.com", "password-valid-123"), "", "") + expect(t, w, tc.status) + if tc.status == 401 && !strings.Contains(w.Body.String(), "invalid email or password") { + t.Fatal(w.Body.String()) + } + if revoked != tc.revoke || len(w.Result().Cookies()) != 0 || strings.Contains(w.Body.String(), "private") { + t.Fatal("failed login leaked or changed credentials", w.Body.String()) + } + var n int + if err := s.db.QueryRow(`SELECT count(*) FROM users`).Scan(&n); err != nil || n != 0 { + t.Fatal("failed login created account", err) + } + }) + } +} +func TestSharedPasswordEnrollmentRequiresValidOTPAndPreservesOptionalFlow(t *testing.T) { + s := testServer(t, nil) + s.cfg.SharedPasswordEnabled = true + enrolls, finishes := 0, 0 + p := &passwordShared{enrollFn: func(_ context.Context, id, code, password string) (sharedaccount.Issued, error) { + enrolls++ + if id != "01ARZ3NDEKTSV4RRFFQ69G5FAV" || password != "password-valid-123" { + t.Fatal("changed enrollment") + } + if code != "123456" { + return sharedaccount.Issued{}, sharedaccount.ErrUnauthorized + } + return sharedaccount.Issued{Token: sharedTestToken, UID: "0123456789abcdef"}, nil + }} + p.finishFn = func(context.Context, string, string) (sharedaccount.Issued, error) { + finishes++ + return sharedaccount.Issued{Token: sharedTestToken, UID: "0123456789abcdef"}, nil + } + s.cfg.Shared = p + for _, password := range []string{"", strings.Repeat("a", 11), strings.Repeat("a", 257), strings.Repeat("é", 129)} { + body, _ := json.Marshal(map[string]string{"request_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", "code": "123456", "password": password}) + expect(t, sharedJSON(s, "/api/auth/verify", string(body), "", ""), 400) + } + if enrolls != 0 || finishes != 0 { + t.Fatal("invalid enrollment reached provider") + } + w := sharedJSON(s, "/api/auth/verify", `{"request_id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","code":"000000","password":"password-valid-123"}`, "", "") + expect(t, w, 401) + if len(w.Result().Cookies()) != 0 { + t.Fatal("invalid OTP signed in") + } + expect(t, sharedJSON(s, "/api/auth/verify", `{"request_id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","code":"123456","password":"password-valid-123"}`, "", ""), 200) + expect(t, sharedJSON(s, "/api/auth/verify", `{"request_id":"01ARZ3NDEKTSV4RRFFQ69G5FAV","code":"123456"}`, "", ""), 200) + if enrolls != 2 || finishes != 1 { + t.Fatal("OTP and password enrollment confused", enrolls, finishes) + } +} +func TestSharedPasswordInvalidCredentialsAndIPRateLimit(t *testing.T) { + s := testServer(t, nil) + s.cfg.SharedPasswordEnabled = true + calls := 0 + s.cfg.Shared = &passwordShared{passwordFn: func(context.Context, string, string) (sharedaccount.Issued, error) { + calls++ + return sharedaccount.Issued{}, sharedaccount.ErrUnauthorized + }} + for _, body := range []string{passwordBody("invalid", "password-valid-123"), passwordBody("shared@example.com", "short"), passwordBody("shared@example.com", strings.Repeat("a", 257))} { + expect(t, sharedJSON(s, "/api/auth/password", body, "", "192.0.2.3:1"), 401) + } + if calls != 0 { + t.Fatal("invalid credentials reached upstream") + } + for i := 0; i < 10; i++ { + expect(t, sharedJSON(s, "/api/auth/password", passwordBody("shared@example.com", "password-valid-123"), "", "192.0.2.1:1"), 401) + } + expect(t, sharedJSON(s, "/api/auth/password", passwordBody("shared@example.com", "password-valid-123"), "", "192.0.2.1:2"), 429) + expect(t, sharedJSON(s, "/api/auth/password", passwordBody("shared@example.com", "password-valid-123"), "", "192.0.2.2:1"), 401) + if calls != 11 { + t.Fatal("rate limited request reached upstream", calls) + } +} diff --git a/internal/community/shared_test.go b/internal/community/shared_test.go index 9aee1db..d1e0b7e 100644 --- a/internal/community/shared_test.go +++ b/internal/community/shared_test.go @@ -94,7 +94,7 @@ func TestSharedTokensAccountIsolationQuotasAndMCPParameters(t *testing.T) { t.Fatal("quota did not isolate users", hits) } var creditBalance int - if err := s.db.QueryRow(`SELECT sum(delta) FROM credit_ledger`).Scan(&creditBalance); err != nil || creditBalance != monthlyFreeCredits-1 { + if err := s.db.QueryRow(`SELECT sum(delta) FROM credit_ledger`).Scan(&creditBalance); err != nil || creditBalance != 2*monthlyFreeCredits-3 { t.Fatalf("shared bearer request did not use monthly credits: %d %v", creditBalance, err) } f.err = sharedaccount.ErrUnavailable diff --git a/internal/community/shared_wire_test.go b/internal/community/shared_wire_test.go index 26cfe5c..2ebeb7a 100644 --- a/internal/community/shared_wire_test.go +++ b/internal/community/shared_wire_test.go @@ -30,7 +30,7 @@ func TestMCPGatewayContract(t *testing.T) { _, _ = w.Write([]byte(`{"query":"rust","retriever":"bm25","hits":[]}`)) })) s.cfg.Shared = &fakeShared{} - s.cfg.MemberFreeRPM = 1 + s.cfg.SearchRPM = 1 gateway := httptest.NewServer(s) defer gateway.Close() script, err := filepath.Abs("../../integrations/cosift-mcp/search_contract.py") @@ -49,5 +49,9 @@ func TestMCPGatewayContract(t *testing.T) { if hits.Load() != 2 { t.Fatalf("engine received %d requests, want two accounts once", hits.Load()) } + var balance int + if err := s.db.QueryRow(`SELECT sum(delta) FROM credit_ledger`).Scan(&balance); err != nil || balance != 2*monthlyFreeCredits-2 { + t.Fatalf("MCP requests did not debit both accounts: %d %v", balance, err) + } t.Log(string(out)) } diff --git a/internal/community/subscriptions.go b/internal/community/subscriptions.go index 9edc2ce..ac93ed0 100644 --- a/internal/community/subscriptions.go +++ b/internal/community/subscriptions.go @@ -187,7 +187,7 @@ func (s *Server) checkoutSubscription(w http.ResponseWriter, r *http.Request, u "mode": {"subscription"}, "payment_method_types[0]": {"card"}, "adaptive_pricing[enabled]": {"false"}, "client_reference_id": {u.ID}, "metadata[cosift_order_id]": {id}, "subscription_data[metadata][cosift_order_id]": {id}, "line_items[0][price_data][currency]": {"usd"}, "line_items[0][price_data][unit_amount]": {"500"}, "line_items[0][price_data][recurring][interval]": {"month"}, "line_items[0][price_data][recurring][interval_count]": {"1"}, - "line_items[0][price_data][product_data][name]": {"Cosift monthly credits"}, "line_items[0][price_data][product_data][description]": {"50,000 credits per paid month. Unused credits carry over. Search 1, Answer 2, Research 3 credits per extra request."}, "line_items[0][quantity]": {"1"}, + "line_items[0][price_data][product_data][name]": {"Cosift monthly credits"}, "line_items[0][price_data][product_data][description]": {"50,000 credits per paid month. Unused credits carry over. Search 1, Answer 2, Research 3 credits per request."}, "line_items[0][quantity]": {"1"}, "expires_at": {strconv.FormatInt(created+23*3600, 10)}, "success_url": {s.cfg.PublicURL + "/?payment=success"}, "cancel_url": {s.cfg.PublicURL + "/?payment=cancelled"}, } var session stripe.CheckoutSession diff --git a/internal/community/subscriptions_checkout_test.go b/internal/community/subscriptions_checkout_test.go index 6b6744c..71a9bdf 100644 --- a/internal/community/subscriptions_checkout_test.go +++ b/internal/community/subscriptions_checkout_test.go @@ -93,7 +93,7 @@ func TestSubscriptionCheckoutCompletionNeverGrantsMonthlyCredits(t *testing.T) { if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil { t.Fatal(err) } - if info["can_top_up"] != false || info["monthly_free_credits"] != float64(1000) || info["free_requests_per_minute"] != float64(60) { + if info["can_top_up"] != false || info["monthly_free_credits"] != float64(1000) || info["free_requests_per_minute"] != float64(0) || info["all_authenticated_requests_metered"] != true { t.Fatal("unpaid subscription changed free plan or enabled top-ups") } if info["subscription"].(map[string]any)["active"] != false { diff --git a/internal/community/web/app.js b/internal/community/web/app.js index 383dac8..b805c43 100644 --- a/internal/community/web/app.js +++ b/internal/community/web/app.js @@ -14,6 +14,7 @@ const pendingRequests = new Set(); let searchRequest; let authBusy = false; let sharedAuth = false, authChallenge = null, authConfigured = false; +let supportsPassword = false, sharedLoginMode = "otp"; function resetAccount(nextUser = null) { accountGeneration++; for (const controller of pendingRequests) controller.abort(); @@ -205,25 +206,28 @@ $("auth-form").onsubmit = (event) => { resetAccount(); const form = new FormData(event.target); if (sharedAuth) { + if (sharedLoginMode === "password" && supportsPassword) { + user = await api("auth/password", "POST", {email: form.get("email"), password: form.get("password")}); + sharedLoginMode = "otp"; + event.target.reset(); + renderSharedAuth(); + await enterAfterLogin(); + return; + } if (!authChallenge) { authChallenge = await api("auth/start", "POST", {email: form.get("email")}); - $("code-field").hidden = false; - $("auth-restart").hidden = false; - event.target.elements.code.required = true; - event.target.elements.email.readOnly = true; - $("auth-submit").textContent = "Verify and sign in →"; + renderSharedAuth(); notify("If the address is eligible, a verification code is on its way. Check your email."); return; } - user = await api("auth/verify", "POST", {request_id: authChallenge.request_id, code: form.get("code")}); + const verification = {request_id: authChallenge.request_id, code: form.get("code")}; + if (sharedLoginMode === "setup" && supportsPassword) verification.password = form.get("password"); + user = await api("auth/verify", "POST", verification); authChallenge = null; - $("code-field").hidden = true; - $("auth-restart").hidden = true; - event.target.elements.code.required = false; - event.target.elements.email.readOnly = false; - $("auth-submit").textContent = "Email me a code →"; + sharedLoginMode = "otp"; event.target.reset(); - await enter(); + renderSharedAuth(); + await enterAfterLogin(); return; } user = await api(signingUp ? "register" : "login", "POST", { @@ -235,6 +239,50 @@ $("auth-form").onsubmit = (event) => { await enter(); }).finally(() => { authBusy = false; }); }; +function renderSharedAuth() { + const form = $("auth-form"), checkingCode = !!authChallenge; + const passwordLogin = sharedLoginMode === "password", settingPassword = sharedLoginMode === "setup"; + $("name-field").hidden = true; + form.elements.name.required = false; + $("auth-switch").hidden = true; + $("password-field").hidden = !(passwordLogin || settingPassword && checkingCode); + form.elements.password.required = !$("password-field").hidden; + form.elements.password.autocomplete = settingPassword ? "new-password" : "current-password"; + form.elements.password.placeholder = settingPassword ? "Choose a password (at least 12 characters)" : "Your password"; + $("code-field").hidden = !checkingCode; + form.elements.code.required = checkingCode; + form.elements.email.readOnly = checkingCode; + $("auth-restart").hidden = !checkingCode; + $("auth-password-switch").hidden = !supportsPassword; + $("auth-password-switch").textContent = sharedLoginMode === "otp" ? "Use email & password" : "Use an email code instead"; + $("auth-password-reset").hidden = !supportsPassword || !passwordLogin; + $("auth-title").textContent = settingPassword ? "Set your password" : "Sign in to Cosift"; + $("auth-description").textContent = settingPassword ? "Verify your email to set or reset your password. Your account and credits stay the same." + : passwordLogin ? "Use the password you set for your Cosift account." : "We’ll email you a sign-in code. No password needed."; + $("auth-submit").textContent = passwordLogin ? "Sign in →" : checkingCode ? settingPassword ? "Set password and sign in →" : "Verify and sign in →" : "Email me a code →"; +} +function selectSharedLogin(mode) { + if (authBusy || !sharedAuth || !supportsPassword) return; + sharedLoginMode = mode; + authChallenge = null; + $("auth-form").elements.password.value = ""; + $("auth-form").elements.code.value = ""; + renderSharedAuth(); +} +$("auth-password-switch").onclick = () => selectSharedLogin(sharedLoginMode === "otp" ? "password" : "otp"); +$("auth-password-reset").onclick = () => selectSharedLogin("setup"); +async function enterAfterLogin() { + showScreen("boot"); + try { await enter(); } + catch (e) { + if (user) { + $("boot-message").textContent = "You’re signed in, but your workspace couldn’t load. Please try again."; + $("boot-spinner").hidden = true; + $("boot-retry").hidden = false; + } + throw e; + } +} function onboarding() { selected = new Set(user.interests.filter((v) => topics.includes(v))); $("custom-interests").value = user.interests @@ -325,7 +373,7 @@ async function refreshCredits() { $("buy-credits").textContent = `Top up ${pack.credits.toLocaleString()} credits · ${price}`; $("buy-credits").hidden = !c.payments_enabled || !c.can_top_up; $("payment-info").hidden = false; - $("payment-info").textContent = "Extra requests: Search 1 credit · Answer 2 credits · Research 3 credits. Existing rate caps apply."; + $("payment-info").textContent = "Request costs: Search 1 credit · Answer 2 credits · Research 3 credits. Existing rate caps apply."; } } } @@ -788,14 +836,16 @@ $("refresh-contributions").onclick = () => let requestPolicy; async function refreshLimits() { requestPolicy = await api("limits"); - const interval = requestPolicy.guest_interval_seconds; const duration = (seconds) => seconds % 60 === 0 ? `${seconds / 60} min` : `${seconds} sec`; const describe = (limits) => Object.entries(limits).map(([mode, limit]) => `${modeLabels[mode]} ${limit.requests}/${duration(limit.window_seconds)}`).join(" · "); - $("guest-policy").textContent = `Guests: one shared request every ${duration(interval)}. ${describe(requestPolicy.guest)}.`; + const guestCooldowns = ["search", "answer", "research"].map((mode) => + `${modeLabels[mode]} ${duration(requestPolicy.guest[mode].window_seconds)}`).join(" · "); + const guestPolicy = `Shared guest cooldown: ${guestCooldowns}. A request pauses all three modes.`; + $("guest-policy").textContent = guestPolicy; $("request-limits").textContent = user - ? `${requestPolicy.member_free_requests_per_minute} free requests/min. Extra: Search 1 credit · Answer 2 · Research 3. ${describe(requestPolicy.member)}.` - : describe(requestPolicy.guest); + ? `Search 1 credit · Answer 2 · Research 3. ${describe(requestPolicy.member)}.` + : guestPolicy; } let startupPending = false; async function initialize() { @@ -809,17 +859,11 @@ async function initialize() { try { const authConfig = await api("auth/config"); sharedAuth = authConfig.shared === true; + supportsPassword = sharedAuth && authConfig.supports_password === true; authConfigured = true; $("auth-submit").disabled = false; if (sharedAuth) { - $("name-field").hidden = true; - $("password-field").hidden = true; - $("auth-switch").hidden = true; - $("auth-form").elements.name.required = false; - $("auth-form").elements.password.required = false; - $("auth-title").textContent = "Sign in to Cosift."; - $("auth-description").textContent = "Use the same email as your connected agents. We’ll send you a verification code."; - $("auth-submit").textContent = "Email me a code →"; + renderSharedAuth(); $("interests-explanation").textContent = "Save interests to follow these topics across Cosift and your connected agents. Existing agent topics stay followed; remove them in Followed topics."; } try { user = await api("me"); } @@ -1007,6 +1051,8 @@ $("auth-restart").onclick = () => { $("code-field").hidden = true; $("auth-restart").hidden = true; $("auth-submit").textContent = "Email me a code →"; + form.elements.password.value = ""; + if (sharedAuth) renderSharedAuth(); }; // The application sends only sanitized pageviews, with no search/account payload. diff --git a/internal/community/web/index.html b/internal/community/web/index.html index 2b5ce14..ce4a5c0 100644 --- a/internal/community/web/index.html +++ b/internal/community/web/index.html @@ -32,6 +32,7 @@
Use the same email as your CLI and agents.
+Sign in for 1,000 free credits every month. No subscription required.
+Already have an account? @@ -145,7 +150,7 @@