Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,38 @@ class Settings(BaseSettings):
AUTHZ_HOOK_URL: str = ""
AUTHZ_HOOK_SECRET: str = ""

# ─── Dock-readiness packaging knobs (PR-5d) ─────────────────────
#
# Self-host integrators (Dock first; future others) often want to
# ship cueapi-core as a SUBSET of the full surface — for example,
# the messaging primitive without the cron / cues primitive, or
# without the email-driven device-code signup flow, or with quotas
# disabled because the integrator enforces them at their own
# billing layer. These flags are the supported way to do that.
#
# All default to False so default behavior matches the full
# cueapi-core experience. Self-hosters override via env var.

# Strips ``app/routers/cues.py`` + executions + workers + alert routes
# from the FastAPI app at startup. Cron loop (poller) is also disabled
# if running in the same process. Use when integrating cueapi-core as
# a messaging-only substrate (Dock Connect's case).
DISABLE_CUE_PRIMITIVE: bool = False

# Bypasses quota enforcement on:
# - POST /v1/cues (active_cue_limit, monthly_execution_limit)
# - POST /v1/messages (monthly_message_limit, priority rate-limits)
# Use when the integrator's billing/plan layer already gates these
# one level up (Dock enforces per-tier limits in src/lib/plan.ts).
DISABLE_QUOTA_ENFORCEMENT: bool = False

# Strips ``app/routers/device_code.py`` (the email-magic-link signup
# flow) from the FastAPI app at startup. Use when the integrator has
# its own user identity system and writes to the ``users`` table
# directly (Dock mirrors its User table into Cue's via shared
# Postgres or via PUT /v1/internal/users/{user_id} in PR-5c).
DISABLE_DEVICE_CODE: bool = False

@property
def async_database_url(self) -> str:
"""Convert postgresql:// to postgresql+asyncpg://."""
Expand Down
35 changes: 29 additions & 6 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,16 +149,24 @@ async def generic_error_handler(request: Request, exc: Exception):
)


# ─── Router registration ────────────────────────────────────────────
#
# Three packaging-mode flags from app/config.py (PR-5d) gate which
# routers mount at startup. Defaults preserve full cueapi-core
# behavior. Self-host integrators flip flags via env var.
#
# DISABLE_DEVICE_CODE — strips email-magic-link signup flow
# DISABLE_CUE_PRIMITIVE — strips cron / cues / executions / workers
# (DISABLE_QUOTA_ENFORCEMENT lives in service-layer checks, not here)
#
# Always-on routers come first: health, auth (token validation), agents,
# messages, alerts, webhook_secret, usage. These are essential whether
# the deployment is full / messaging-only / cron-only.

app.include_router(health.router)
app.include_router(auth_routes.router)
app.include_router(device_code.router)
app.include_router(device_code.page_router)
app.include_router(cues.router)
app.include_router(executions.router)
app.include_router(usage.router)
app.include_router(echo.router)
app.include_router(workers.router)
app.include_router(workers.workers_list_router)
app.include_router(webhook_secret.router)
app.include_router(alerts.router)
app.include_router(agents.router)
Expand All @@ -169,3 +177,18 @@ async def generic_error_handler(request: Request, exc: Exception):
# auth model. Default deployments don't expose ``/v1/internal/*``.
if settings.EXTERNAL_AUTH_BACKEND:
app.include_router(internal_users.router)

# ─── PR-5d: packaging-mode flags ──────────────────────────────────
if not settings.DISABLE_DEVICE_CODE:
# Email-magic-link signup flow. Disable when the integrator has
# its own user identity system.
app.include_router(device_code.router)
app.include_router(device_code.page_router)

if not settings.DISABLE_CUE_PRIMITIVE:
# Cue / cron / executions / workers surface. Disable when running
# as a messaging-only substrate (Dock Connect's case).
app.include_router(cues.router)
app.include_router(executions.router)
app.include_router(workers.router)
app.include_router(workers.workers_list_router)
23 changes: 13 additions & 10 deletions app/services/cue_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,16 +159,19 @@ async def create_cue(db: AsyncSession, user: AuthenticatedUser, data: CueCreate)
}

# Check cue limit
count_result = await db.execute(
select(func.count())
.select_from(Cue)
.where(Cue.user_id == user.id, Cue.status.in_(["active", "paused"]))
)
active_count = count_result.scalar()
if active_count >= user.active_cue_limit:
return {
"error": {"code": "cue_limit_exceeded", "message": f"Active cue limit of {user.active_cue_limit} reached", "status": 403}
}
# Skipped when DISABLE_QUOTA_ENFORCEMENT=true (PR-5d) — integrators
# like Dock enforce per-tier limits at their own billing layer.
if not settings.DISABLE_QUOTA_ENFORCEMENT:
count_result = await db.execute(
select(func.count())
.select_from(Cue)
.where(Cue.user_id == user.id, Cue.status.in_(["active", "paused"]))
)
active_count = count_result.scalar()
if active_count >= user.active_cue_limit:
return {
"error": {"code": "cue_limit_exceeded", "message": f"Active cue limit of {user.active_cue_limit} reached", "status": 403}
}

# Validate callback URL (SSRF protection) — skip for worker transport
transport = data.transport or "webhook"
Expand Down
40 changes: 25 additions & 15 deletions app/services/message_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth import AuthenticatedUser
from app.config import settings
from app.models import Agent, DispatchOutbox, Message
from app.redis import get_redis
from app.services.agent_service import resolve_address
Expand Down Expand Up @@ -226,22 +227,29 @@ async def create_message(
# BEFORE the idempotency check — a dedup-hit on a key inside a
# rate-limited window should still be allowed (it's not a new
# message, it's the cached representation of an old one).
#
# When DISABLE_QUOTA_ENFORCEMENT=true (PR-5d), all three checks
# (per-minute rate, priority anti-abuse, monthly quota below) are
# bypassed. Integrators like Dock enforce per-tier limits at their
# own billing layer.
redis = await get_redis()
plan, monthly_limit = await get_user_plan_and_msg_limit(db, user.id)

# Per-minute rate limit (sliding window, plan-tiered).
await check_per_minute_rate_limit(user.id, plan, redis)

# Priority-high anti-abuse — may downgrade priority to 3 silently
# for over-pair, or 429 for over-sender.
effective_priority, priority_downgraded = await check_priority_high_limits(
user_id=user.id,
from_agent_id=from_agent.id,
to_agent_id=to_agent.id,
priority=priority,
redis=redis,
)
priority = effective_priority
priority_downgraded = False

if not settings.DISABLE_QUOTA_ENFORCEMENT:
# Per-minute rate limit (sliding window, plan-tiered).
await check_per_minute_rate_limit(user.id, plan, redis)

# Priority-high anti-abuse — may downgrade priority to 3 silently
# for over-pair, or 429 for over-sender.
effective_priority, priority_downgraded = await check_priority_high_limits(
user_id=user.id,
from_agent_id=from_agent.id,
to_agent_id=to_agent.id,
priority=priority,
redis=redis,
)
priority = effective_priority

# 5. Idempotency check.
fingerprint = _compute_fingerprint(
Expand Down Expand Up @@ -271,7 +279,9 @@ async def create_message(

# 5.5. Monthly quota — checked here, AFTER idempotency. A dedup-hit
# is not a new message; the quota check applies only to new sends.
await check_message_quota(db, user.id, monthly_limit, redis)
# Bypassed under DISABLE_QUOTA_ENFORCEMENT (PR-5d).
if not settings.DISABLE_QUOTA_ENFORCEMENT:
await check_message_quota(db, user.id, monthly_limit, redis)

# 6. Resolve thread_id + reply_to chain.
inherited_thread_id, parent_msg_id = await _resolve_reply_to(
Expand Down
Loading
Loading