gabriel / musehub public
Open #31
filed by gabriel human · 43 days ago

Pro Tier: plan gating, MPay subscriptions, and web session bootstrap

0 Anchors
Blast radius
Churn 30d
0 Proposals

Summary

Introduce the MuseHub Pro tier. Three interlocking pieces:

  1. Plan gating — add a plan column to MusehubIdentity, gate secret mists and private repos behind require_pro, expose is_pro on MSignContext.
  2. MPay subscriptions — accept signed PaymentClaim attestations at POST /api/billing/subscribe to upgrade a handle's plan, with chain-linkable Avalanche C-Chain dual-signature support.
  3. Web session bootstrap — let the CLI mint a short-lived browser session from an MSign key so the web UI can show authenticated views (secret repos, upgrade flow, settings).

Everything is additive. No existing routes change their auth contracts. Free-tier behaviour is identical to today.


Architecture

                        ┌─────────────────────────────────┐
                        │      MusehubIdentity (DB)        │
                        │  plan: "free" | "pro" | "team"   │
                        │  plan_expires_at: datetime | None │
                        └──────────────┬──────────────────┘
                                       │ loaded by _verify_msign()
                        ┌──────────────▼──────────────────┐
                        │         MSignContext             │
                        │  handle, identity_id, is_agent   │
                        │  is_admin, scope, plan  ◄── NEW  │
                        └──────────────┬──────────────────┘
             ┌─────────────────────────┼──────────────────────┐
             │                         │                      │
  ┌──────────▼──────────┐  ┌──────────▼──────────┐  ┌───────▼───────────┐
  │   require_pro()     │  │  POST /api/billing/  │  │  Web session      │
  │   (dep factory)     │  │  subscribe           │  │  bootstrap        │
  │   gates secret      │  │  (verifies PaymentClaim│ │  POST /api/auth/  │
  │   mists/repos       │  │   upgrades plan)     │  │  web-session      │
  └─────────────────────┘  └──────────────────────┘  └───────────────────┘

Phase 1 — Plan column + MSignContext extension

1.1 DB model (musehub/db/musehub_models.py)

After line 394 (after tos_version), add two columns to MusehubIdentity:

# Subscription tier.  "free" is the default.
# "pro", "team", "enterprise" gate features via require_pro().
plan: Mapped[str] = mapped_column(
    String(20), nullable=False, default="free", server_default="free"
)
# UTC expiry of the active paid plan.  None = plan never expires
# (for lifetime plans or manually-granted access).
plan_expires_at: Mapped[datetime | None] = mapped_column(
    DateTime(timezone=True), nullable=True, default=None
)

Docstring on MusehubIdentity must be updated to document both fields.

1.2 Migration (alembic/versions/0049_identity_plan_tier.py)

New Alembic migration — next in sequence after 0048_add_mists_table.py.

"""Add plan and plan_expires_at to musehub_identities.

Revision ID: 0049
Revises: 0048
"""
from alembic import op
import sqlalchemy as sa

revision = "0049"
down_revision = "0048"
branch_labels = None
depends_on = None

def upgrade() -> None:
    op.add_column(
        "musehub_identities",
        sa.Column("plan", sa.String(20), nullable=False, server_default="free"),
    )
    op.add_column(
        "musehub_identities",
        sa.Column("plan_expires_at", sa.DateTime(timezone=True), nullable=True),
    )
    op.create_index(
        "ix_musehub_identities_plan",
        "musehub_identities",
        ["plan"],
    )

def downgrade() -> None:
    op.drop_index("ix_musehub_identities_plan", "musehub_identities")
    op.drop_column("musehub_identities", "plan_expires_at")
    op.drop_column("musehub_identities", "plan")

1.3 MSignContext (musehub/auth/request_signing.py)

Add plan and plan_expires_at to MSignContext (line ~61):

@dataclass
class MSignContext:
    """Verified request context — passed as the dependency result to route handlers.

    ``scope`` mirrors the value stored on ``MusehubIdentity.scope``:
    - ``None``       — human identity; no capability restrictions apply.
    - ``[...]``      — agent identity; only the listed capability tokens are
                       permitted.  Routes enforce this via ``require_scope()``.

    ``plan`` mirrors ``MusehubIdentity.plan``.  Use ``ctx.is_pro`` (property)
    rather than comparing the string directly — it handles expiry logic.
    """
    handle: str
    identity_id: str
    is_agent: bool
    is_admin: bool
    scope: list[str] | None = field(default=None)
    plan: str = field(default="free")
    plan_expires_at: datetime | None = field(default=None)

    @property
    def is_pro(self) -> bool:
        """True when the identity holds an active paid plan.

        A plan is active when:
        - ``plan`` is not ``"free"``, AND
        - ``plan_expires_at`` is ``None`` (lifetime) OR is in the future.
        """
        if self.plan == "free":
            return False
        if self.plan_expires_at is None:
            return True
        return datetime.now(tz=timezone.utc) < self.plan_expires_at

Update _verify_msign() (line ~96) to load plan and plan_expires_at from the identity row and populate the new fields on the returned MSignContext.

1.4 Test conftest (tests/conftest.py)

_TEST_CONTEXT (line 281) must be updated — add plan="free" to match the new field. Add a second fixture pro_auth_headers that uses plan="pro" for pro-tier tests:

_PRO_CONTEXT = MSignContext(
    handle=_TEST_HANDLE,
    identity_id=_TEST_IDENTITY_ID,
    is_agent=False,
    is_admin=False,
    plan="pro",
)

@pytest.fixture
def pro_auth_headers(test_user: MusehubIdentity) -> Generator[dict, None, None]:
    """Like auth_headers but injects a Pro-tier MSignContext."""
    app.dependency_overrides[require_signed_request] = lambda: _PRO_CONTEXT
    app.dependency_overrides[optional_signed_request] = lambda: _PRO_CONTEXT
    yield {"Content-Type": "application/json"}
    app.dependency_overrides.pop(require_signed_request, None)
    app.dependency_overrides.pop(optional_signed_request, None)

Phase 2 — require_pro dependency + feature gating

2.1 require_pro factory (musehub/auth/request_signing.py)

Add after require_scope():

def require_pro(feature: str = "this feature") -> Callable[..., Awaitable[MSignContext]]:
    """Dependency factory: require a valid MSign header AND an active Pro plan.

    Raises HTTP 402 (Payment Required) when the caller's plan is "free" or
    when a paid plan has expired.  The ``feature`` argument names the gated
    capability in the error message so callers get a clear upgrade prompt.

    Usage::

        @router.post("/api/mists")
        async def create_mist(
            body: MistCreateRequest,
            claims: MSignContext = Depends(require_pro("secret mists")),
        ) -> ...:
            ...
    """
    async def _dependency(
        claims: MSignContext = Depends(require_signed_request),
    ) -> MSignContext:
        if not claims.is_pro:
            raise HTTPException(
                status_code=status.HTTP_402_PAYMENT_REQUIRED,
                detail=(
                    f"{feature} requires a Pro plan. "
                    "Upgrade at https://musehub.ai/upgrade"
                ),
            )
        return claims
    return _dependency

Export from musehub/auth/__init__.py and musehub/auth/dependencies.py.

2.2 Visibility gating in mist routes (musehub/api/routes/musehub/mists.py)

In create_mist() handler (line ~84), after Pydantic validation succeeds, add:

if body.visibility == "secret" and not claims.is_pro:
    raise HTTPException(
        status_code=status.HTTP_402_PAYMENT_REQUIRED,
        detail="Secret mists require a Pro plan. Upgrade at https://musehub.ai/upgrade",
    )

Same check in update_mist() handler (line ~250):

if body.visibility == "secret" and not claims.is_pro:
    raise HTTPException(
        status_code=status.HTTP_402_PAYMENT_REQUIRED,
        detail="Secret mists require a Pro plan. Upgrade at https://musehub.ai/upgrade",
    )

2.3 Visibility gating in repo routes (musehub/api/routes/musehub/repos.py)

Same pattern — gate visibility="private" on POST /api/repos and PATCH /api/repos/{slug} behind claims.is_pro.

2.4 Rate limits (musehub/rate_limits.py)

Add:

BILLING_LIMIT: str = "10/minute"
WEB_SESSION_LIMIT: str = "5/minute"

Phase 3 — MPay subscription endpoint

3.1 Billing models (musehub/models/billing.py) ← new file

"""Pydantic models for the MPay billing endpoints.

POST /api/billing/subscribe  — upgrade plan via a signed PaymentClaim
GET  /api/billing/plan       — current plan for the authenticated handle
"""

class SubscribeRequest(CamelModel):
    """Validated body for POST /api/billing/subscribe.

    Callers must produce a PaymentClaim via ``muse.core.msign.build_payment_claim``
    with:
      - ``to_handle="musehub"``
      - ``memo="pro:monthly"`` (or ``"pro:annual"``)
      - ``currency="nanoMUSE"``
      - ``amount_nano >= PRO_MONTHLY_PRICE_NANO``

    The server verifies the Ed25519 signature against the caller's registered
    key before upgrading the plan.
    """
    from_handle: str
    to_handle: str
    amount_nano: int
    currency: str
    nonce_hex: str
    memo: str
    ts: int
    signature_b64: str
    canonical_message: str
    # Optional AVAX dual-sig fields
    payer_avax_address: str | None = None
    recipient_avax_address: str | None = None
    eth_sig: str | None = None


class PlanResponse(CamelModel):
    """Current plan for an identity."""
    handle: str
    plan: str
    plan_expires_at: datetime | None
    is_pro: bool

3.2 Billing service (musehub/services/musehub_billing.py) ← new file

Key functions (all must have full Google-style docstrings):

PRO_MONTHLY_PRICE_NANO: int = 9_900_000_000   # 9.9 MUSE
PRO_ANNUAL_PRICE_NANO:  int = 99_000_000_000  # 99 MUSE
_PLAN_BY_MEMO: dict[str, str] = {
    "pro:monthly": "pro",
    "pro:annual":  "pro",
}
_DURATION_BY_MEMO: dict[str, timedelta] = {
    "pro:monthly": timedelta(days=31),
    "pro:annual":  timedelta(days=366),
}

async def verify_payment_claim(
    db: AsyncSession,
    claim: SubscribeRequest,
    caller_handle: str,
) -> None:
    """Verify an MPay PaymentClaim against the caller's registered Ed25519 key.

    Raises BillingError (subclass of HTTPException) on any verification failure:
    - from_handle != caller_handle
    - to_handle != "musehub"
    - unknown or expired memo
    - amount_nano below the required minimum for the requested plan
    - signature invalid or timestamp stale (±30 s)
    - nonce already used (replay protection)
    """
    ...

async def apply_subscription(
    db: AsyncSession,
    handle: str,
    plan: str,
    duration: timedelta,
) -> PlanResponse:
    """Upgrade an identity's plan and set plan_expires_at.

    Idempotent: if the handle already holds the same plan with a future
    expiry, the expiry is extended by ``duration`` rather than reset to
    ``now + duration``.
    """
    ...

async def get_plan(db: AsyncSession, handle: str) -> PlanResponse:
    """Return the current plan for a handle."""
    ...

Also introduce a UsedNonce ORM model and migration to prevent MPay replay attacks:

musehub_used_mpay_nonces (
    nonce_hex TEXT PRIMARY KEY,
    handle    TEXT NOT NULL,
    used_at   TIMESTAMPTZ NOT NULL DEFAULT now()
)

Migration: 0050_mpay_used_nonces.py.

3.3 Billing routes (musehub/api/routes/api/billing.py) ← new file

POST /api/billing/subscribe  — MSign required; accepts SubscribeRequest; upgrades plan
GET  /api/billing/plan       — MSign required; returns PlanResponse

Register in musehub/main.py alongside the other /api/ routers.


Phase 4 — Web session bootstrap

4.1 DB model (musehub/db/musehub_models.py)

New ORM class MusehubWebSession:

class MusehubWebSession(Base):
    """Short-lived browser session minted from an MSign key via the CLI.

    Sessions are single-use by default: a second visit to the activation URL
    after the first returns 410 Gone.  The TTL is configurable (default 8 h).

    Columns
    -------
    session_id : UUID PK — appears in the activation URL path
    handle     : identity handle that created the session
    expires_at : hard expiry; session is invalid after this point
    used_at    : None until the browser visits /auth/s/{session_id}; set on first use
    revoked    : True when the owner has revoked via POST /api/auth/web-session/revoke
    created_at : creation timestamp
    """
    __tablename__ = "musehub_web_sessions"

    session_id:  Mapped[str]
    handle:      Mapped[str]
    expires_at:  Mapped[datetime]
    used_at:     Mapped[datetime | None]
    revoked:     Mapped[bool]
    created_at:  Mapped[datetime]

Migration: 0051_web_sessions.py.

4.2 Session service (musehub/services/musehub_web_sessions.py) ← new file

DEFAULT_SESSION_TTL: timedelta = timedelta(hours=8)
MAX_SESSION_TTL:     timedelta = timedelta(days=30)
COOKIE_NAME:         str       = "muse_session"
COOKIE_MAX_AGE:      int       = int(DEFAULT_SESSION_TTL.total_seconds())

async def create_web_session(db, handle, ttl=DEFAULT_SESSION_TTL) -> MusehubWebSession: ...
async def activate_web_session(db, session_id) -> MusehubWebSession: ...
async def resolve_cookie(db, session_id) -> MusehubWebSession | None: ...
async def revoke_web_session(db, session_id, caller_handle) -> None: ...

4.3 Auth routes addition (musehub/api/routes/api/auth.py)

POST /api/auth/web-session          — MSign required; creates session; returns sessionUrl
GET  /auth/s/{session_id}           — public; activates session; sets cookie; redirects
POST /api/auth/web-session/revoke   — MSign required; revokes session by ID

4.4 Session middleware (musehub/middleware/session.py) ← new file

class WebSessionMiddleware(BaseHTTPMiddleware):
    """Resolves muse_session cookie on every request.
    Sets request.state.web_session_handle and request.state.web_session_is_pro
    for SSR templates. Does not replace MSign auth for API routes.
    """

4.5 CLI commands (muse/muse/cli/commands/auth.py)

muse auth web-session [--ttl 8h] [--open] [--hub URL] [--json]
muse auth web-session revoke <session_id> [--hub URL] [--json]

4.6 CLI billing commands (muse/muse/cli/commands/billing.py) ← new file

muse billing subscribe [--plan pro] [--period monthly|annual] [--hub URL] [--json]
muse billing status [--hub URL] [--json]

Phase 5 — Documentation + upgrade UI page

5.1 Reference doc (docs/reference/pro-tier.md) ← new file

Sections: Plan tiers table, MPay subscription flow, web session bootstrap sequence diagram, CLI examples, API reference for all new endpoints, security model notes.

5.2 Upgrade page (musehub/templates/musehub/pages/upgrade.html) ← new file

Simple SSR page at /upgrade: plan comparison table (Free vs Pro vs Team), CLI instructions for muse billing subscribe, pricing in nanoMUSE.


Testing — All Eight Tiers

Tier 1 — Pure unit tests (tests/test_plan_unit.py)

  • MSignContext.is_pro: free → False, pro + no expiry → True, pro + future expiry → True, pro + past expiry → False, team → True
  • _parse_payment_claim_memo(): valid memos, unknown memo raises, empty raises
  • _clamp_ttl(): under max → unchanged, over max → clamped, zero → raises

Tier 2 — Schema / Pydantic (tests/test_billing_schema.py)

  • SubscribeRequest: valid claim round-trips, to_handle != "musehub" raises, amount_nano < 0 raises
  • WebSessionCreateRequest: valid TTL, TTL > max raises, TTL = 0 raises
  • PlanResponse: camelCase serialisation, is_pro computed field present

Tier 3 — ORM / DB (tests/test_plan_db.py)

  • MusehubIdentity.plan column default is "free"
  • plan_expires_at nullable, accepts None and future datetime
  • MusehubWebSession CRUD: create, activate, revoke, expired row is stale
  • UsedMpayNonce unique constraint: duplicate nonce raises IntegrityError

Tier 4 — Service layer (tests/test_billing_service.py)

  • verify_payment_claim: valid passes, wrong from_handle raises, amount below minimum raises, stale timestamp raises, duplicate nonce raises replay error
  • apply_subscription: new handle → pro, repeated call extends expiry
  • create_web_session: session_id is UUID, used_at is None
  • activate_web_session: sets used_at, second call raises 410, expired raises 410
  • resolve_cookie: valid returns session, expired returns None, revoked returns None

Tier 5 — Route / API contract (tests/test_billing_routes.py, tests/test_web_session_routes.py)

  • POST /api/billing/subscribe 200 on valid, 402 when amount below minimum, 409 on replay, 401 unauthenticated
  • GET /api/billing/plan 200 returns plan, 401 unauthenticated
  • POST /api/auth/web-session 201 + sessionUrl, 401 without MSign, 429 rate-limited
  • GET /auth/s/{session_id} sets cookie + redirects on first visit, 410 on second, 410 expired
  • POST /api/auth/web-session/revoke 204 for owner, 403 non-owner, 404 unknown

Tier 6 — Integration (tests/test_pro_integration.py)

  • Free user: visibility="public" mist → 201; visibility="secret" → 402
  • After upgrade: secret mist → 201
  • Web session full round trip: create → activate → cookie set → handle in request.state
  • Plan expiry: plan_expires_at = now() - 1sis_pro False → secret mist → 402

Tier 7 — Security (tests/test_pro_security.py)

  • require_pro cannot bypass by omitting header (still 401)
  • Tampered canonical_message → signature mismatch → 400
  • Clock skew ±31s → stale → 400
  • Replayed nonce → 409
  • Non-owner session revocation → 403
  • Unknown session_id cookie → middleware resolves to None
  • plan_expires_at = now()-1 in DB → 402 on secret mist
  • Agent identity with plan="pro" passes require_pro

Tier 8 — Docstring audit (tests/test_pro_docstrings.py)

Every new public symbol in the new/modified modules must have a non-empty docstring. Enforced via inspect.getdoc().


File map (new files only)

musehub/
  alembic/versions/
    0049_identity_plan_tier.py
    0050_mpay_used_nonces.py
    0051_web_sessions.py
  models/
    billing.py
  services/
    musehub_billing.py
    musehub_web_sessions.py
  api/routes/api/
    billing.py
  middleware/
    session.py
  templates/musehub/pages/
    upgrade.html
  docs/reference/
    pro-tier.md
  tests/
    test_plan_unit.py
    test_billing_schema.py
    test_plan_db.py
    test_billing_service.py
    test_billing_routes.py
    test_web_session_routes.py
    test_pro_integration.py
    test_pro_security.py
    test_pro_docstrings.py

muse/
  muse/cli/commands/
    billing.py
  (auth.py modified — web-session subcommands added)

Existing files modified

File Change
musehub/db/musehub_models.py Add plan, plan_expires_at, MusehubWebSession
musehub/auth/request_signing.py Extend MSignContext, add require_pro
musehub/auth/__init__.py Export require_pro
musehub/auth/dependencies.py Re-export require_pro
musehub/api/routes/musehub/mists.py Gate visibility="secret" behind is_pro
musehub/api/routes/musehub/repos.py Gate visibility="private" behind is_pro
musehub/api/routes/api/auth.py Add web-session routes
musehub/main.py Register billing router + session middleware
musehub/rate_limits.py Add BILLING_LIMIT, WEB_SESSION_LIMIT
tests/conftest.py Add pro_auth_headers fixture, update _TEST_CONTEXT
muse/muse/cli/commands/auth.py Add web-session subcommands

Docstring standard

Every new public function, class, and method must use Google-style docstrings. No new symbol ships without a docstring. The Tier 8 test file enforces this automatically.


Non-goals for this issue

  • On-chain settlement (Avalanche L1 smart contract) — tracked separately
  • OAuth / OIDC browser login — web session bootstrap covers the near-term need
  • Team/org plan management UI — follow-on
  • Stripe / fiat payment gateway — MPay-only for now
Activity
gabriel opened this issue 43 days ago
No activity yet. Use the CLI to comment.