mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-28 02:23:53 +02:00
Epic 5 Complete: Billing, Subscriptions, and Admin Features
Resolve all 5 deferred items from Epic 5 adversarial code review: - Migration 124: Add CASCADE to subscriptionstatus enum drop (prevent orphaned references) - Stripe rate limiting: In-memory per-user limiter (20 calls/60s) on verify-checkout-session - Subscription request cooldown: 24h cooldown before resubmitting rejected requests - Token reset date: Initialize on first subscription activation - Checkout URL validation: Confirmed HTTPS-only (Stripe always returns HTTPS) Implement Story 5.4 (Usage Tracking & Rate Limit Enforcement): - Page quota pre-check at HTTP upload layer - Extend UserRead schema with token quota fields - Frontend 402 error handling in document upload - Quota indicator in dashboard sidebar Story 5.5 (Admin Seed & Approval Flow): - Seed admin user migration with default credentials warning - Subscription approval/rejection routes with admin guard - 24h rejection cooldown enforcement Story 5.6 (Admin-Only Model Config): - Global model config visible across all search spaces - Per-search-space model configs with user access control - Superuser CRUD for global configs Additional fixes from code review: - PageLimitService: PAST_DUE subscriptions enforce free-tier limits - TokenQuotaService: PAST_DUE subscriptions enforce free-tier limits - Config routes: Fixed user_id.is_(None) filter on mutation endpoints - Stripe webhook: Added guard against silent plan downgrade on unrecognized price_id All changes formatted with Ruff (Python) and Biome (TypeScript). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
20c4f128bb
commit
4eb6ed18d6
41 changed files with 1771 additions and 318 deletions
207
surfsense_backend/alembic/versions/126_seed_admin_user.py
Normal file
207
surfsense_backend/alembic/versions/126_seed_admin_user.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
"""126_seed_admin_user
|
||||
|
||||
Revision ID: 126
|
||||
Revises: 125
|
||||
Create Date: 2026-04-15
|
||||
|
||||
Seeds one admin user on fresh installs (no-op if any user already exists).
|
||||
Credentials are overridable via env vars:
|
||||
ADMIN_EMAIL (default: admin@surfsense.local)
|
||||
ADMIN_PASSWORD (default: Admin@SurfSense1)
|
||||
|
||||
Admin is created with:
|
||||
- is_superuser = TRUE, is_active = TRUE, is_verified = TRUE
|
||||
- subscription_status = 'active', plan_id = 'pro_yearly'
|
||||
- monthly_token_limit = 1_000_000, pages_limit = 5000
|
||||
- A default search space, roles, membership, and prompt defaults
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "126"
|
||||
down_revision: str | None = "125"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
"""Hash password using argon2-cffi (installed as a fastapi-users dependency)."""
|
||||
from argon2 import PasswordHasher
|
||||
|
||||
ph = PasswordHasher()
|
||||
return ph.hash(password)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Only seed when the database is empty
|
||||
result = conn.execute(sa.text('SELECT 1 FROM "user" LIMIT 1'))
|
||||
if result.fetchone() is not None:
|
||||
return # Users already exist — skip seed
|
||||
|
||||
admin_email = os.environ.get("ADMIN_EMAIL", "admin@surfsense.local")
|
||||
admin_password = os.environ.get("ADMIN_PASSWORD", "Admin@SurfSense1")
|
||||
if not os.environ.get("ADMIN_PASSWORD"):
|
||||
print(
|
||||
"\n⚠️ WARNING: ADMIN_PASSWORD env var not set. "
|
||||
"Using default password 'Admin@SurfSense1'. "
|
||||
"Change this immediately after first login!\n"
|
||||
)
|
||||
hashed_pw = _hash_password(admin_password)
|
||||
admin_id = str(uuid.uuid4())
|
||||
|
||||
# 1. Insert admin user
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO "user" (
|
||||
id, email, hashed_password,
|
||||
is_active, is_superuser, is_verified,
|
||||
subscription_status, plan_id,
|
||||
monthly_token_limit, pages_limit, pages_used,
|
||||
tokens_used_this_month
|
||||
) VALUES (
|
||||
:id, :email, :hashed_password,
|
||||
TRUE, TRUE, TRUE,
|
||||
'active', 'pro_yearly',
|
||||
1000000, 5000, 0,
|
||||
0
|
||||
)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": admin_id,
|
||||
"email": admin_email,
|
||||
"hashed_password": hashed_pw,
|
||||
},
|
||||
)
|
||||
|
||||
# 2. Insert default search space for admin (only required columns; defaults handle the rest)
|
||||
search_space_result = conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO searchspaces (name, description, citations_enabled, user_id, created_at)
|
||||
VALUES ('My Search Space', 'Your personal search space', TRUE, :user_id, now())
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{"user_id": admin_id},
|
||||
)
|
||||
search_space_id = search_space_result.fetchone()[0]
|
||||
|
||||
# 3. Insert default roles for the search space
|
||||
owner_role_result = conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO search_space_roles
|
||||
(name, description, permissions, is_default, is_system_role, search_space_id, created_at)
|
||||
VALUES (
|
||||
'Owner', 'Full access to all search space resources and settings',
|
||||
ARRAY['*'], FALSE, TRUE, :ss_id, now()
|
||||
)
|
||||
RETURNING id
|
||||
"""
|
||||
),
|
||||
{"ss_id": search_space_id},
|
||||
)
|
||||
owner_role_id = owner_role_result.fetchone()[0]
|
||||
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO search_space_roles
|
||||
(name, description, permissions, is_default, is_system_role, search_space_id, created_at)
|
||||
VALUES
|
||||
(
|
||||
'Editor',
|
||||
'Can create and update content (no delete, role management, or settings access)',
|
||||
ARRAY[
|
||||
'documents:create','documents:read','documents:update',
|
||||
'chats:create','chats:read','chats:update',
|
||||
'comments:create','comments:read',
|
||||
'llm_configs:create','llm_configs:read','llm_configs:update',
|
||||
'podcasts:create','podcasts:read','podcasts:update',
|
||||
'video_presentations:create','video_presentations:read','video_presentations:update',
|
||||
'image_generations:create','image_generations:read',
|
||||
'vision_configs:create','vision_configs:read',
|
||||
'connectors:create','connectors:read','connectors:update',
|
||||
'logs:read', 'members:invite'
|
||||
],
|
||||
TRUE, TRUE, :ss_id, now()
|
||||
),
|
||||
(
|
||||
'Viewer', 'Read-only access to search space resources',
|
||||
ARRAY[
|
||||
'documents:read','chats:read','comments:read',
|
||||
'llm_configs:read','podcasts:read','video_presentations:read',
|
||||
'image_generations:read','vision_configs:read','connectors:read','logs:read'
|
||||
],
|
||||
FALSE, TRUE, :ss_id, now()
|
||||
)
|
||||
"""
|
||||
),
|
||||
{"ss_id": search_space_id},
|
||||
)
|
||||
|
||||
# 4. Insert owner membership
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO search_space_memberships
|
||||
(user_id, search_space_id, role_id, is_owner, joined_at, created_at)
|
||||
VALUES (:user_id, :ss_id, :role_id, TRUE, now(), now())
|
||||
"""
|
||||
),
|
||||
{"user_id": admin_id, "ss_id": search_space_id, "role_id": owner_role_id},
|
||||
)
|
||||
|
||||
# 5. Insert default prompts (same as migration 114 but just for admin)
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"""
|
||||
INSERT INTO prompts
|
||||
(user_id, default_prompt_slug, name, prompt, mode, version, is_public, created_at)
|
||||
VALUES
|
||||
(:uid, 'fix-grammar', 'Fix grammar',
|
||||
'Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}',
|
||||
'transform'::prompt_mode, 1, false, now()),
|
||||
(:uid, 'make-shorter', 'Make shorter',
|
||||
'Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}',
|
||||
'transform'::prompt_mode, 1, false, now()),
|
||||
(:uid, 'translate', 'Translate',
|
||||
'Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}',
|
||||
'transform'::prompt_mode, 1, false, now()),
|
||||
(:uid, 'rewrite', 'Rewrite',
|
||||
'Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}',
|
||||
'transform'::prompt_mode, 1, false, now()),
|
||||
(:uid, 'summarize', 'Summarize',
|
||||
'Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}',
|
||||
'transform'::prompt_mode, 1, false, now()),
|
||||
(:uid, 'explain', 'Explain',
|
||||
'Explain the following text in simple terms:\n\n{selection}',
|
||||
'explore'::prompt_mode, 1, false, now()),
|
||||
(:uid, 'ask-knowledge-base', 'Ask my knowledge base',
|
||||
'Search my knowledge base for information related to:\n\n{selection}',
|
||||
'explore'::prompt_mode, 1, false, now()),
|
||||
(:uid, 'look-up-web', 'Look up on the web',
|
||||
'Search the web for information about:\n\n{selection}',
|
||||
'explore'::prompt_mode, 1, false, now())
|
||||
ON CONFLICT (user_id, default_prompt_slug) DO NOTHING
|
||||
"""
|
||||
),
|
||||
{"uid": admin_id},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Intentional no-op: never delete users on downgrade
|
||||
pass
|
||||
Loading…
Add table
Add a link
Reference in a new issue