Pro Tier: plan gating, MPay subscriptions, and web session bootstrap
Summary
Introduce the MuseHub Pro tier. Three interlocking pieces:
- Plan gating — add a
plancolumn toMusehubIdentity, gate secret mists and private repos behindrequire_pro, exposeis_proonMSignContext. - MPay subscriptions — accept signed
PaymentClaimattestations atPOST /api/billing/subscribeto upgrade a handle's plan, with chain-linkable Avalanche C-Chain dual-signature support. - 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 raisesWebSessionCreateRequest: valid TTL, TTL > max raises, TTL = 0 raisesPlanResponse: camelCase serialisation,is_procomputed field present
Tier 3 — ORM / DB (tests/test_plan_db.py)
MusehubIdentity.plancolumn default is"free"plan_expires_atnullable, accepts None and future datetimeMusehubWebSessionCRUD: create, activate, revoke, expired row is staleUsedMpayNonceunique 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 errorapply_subscription: new handle → pro, repeated call extends expirycreate_web_session: session_id is UUID, used_at is Noneactivate_web_session: sets used_at, second call raises 410, expired raises 410resolve_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/subscribe200 on valid, 402 when amount below minimum, 409 on replay, 401 unauthenticatedGET /api/billing/plan200 returns plan, 401 unauthenticatedPOST /api/auth/web-session201 + sessionUrl, 401 without MSign, 429 rate-limitedGET /auth/s/{session_id}sets cookie + redirects on first visit, 410 on second, 410 expiredPOST /api/auth/web-session/revoke204 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() - 1s→is_proFalse → secret mist → 402
Tier 7 — Security (tests/test_pro_security.py)
require_procannot 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()-1in DB → 402 on secret mist- Agent identity with
plan="pro"passesrequire_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