diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..0eca3d3 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,66 @@ +# Deploy Allerion to the VPS over SSH. +# +# This workflow is INERT until you add the repo secrets below. With no secrets +# set, the single job short-circuits on its first step and exits successfully +# (it does NOT attempt to SSH anywhere). +# +# Add these in GitHub -> Settings -> Secrets and variables -> Actions -> New +# repository secret: +# +# VPS_HOST (required) the VPS hostname or public IP +# VPS_USER (required) the SSH login user on the VPS +# VPS_SSH_KEY (required) a private SSH key authorized on the VPS (PEM) +# VPS_PORT (optional) SSH port; defaults to 22 if unset +# +# CAVEAT: this assumes the repo is already checked out at ~/allerion on the VPS +# (see deploy/DEPLOY.md step 2 — `git clone ... allerion`). The deploy step only +# fetches/pulls and rebuilds; it does not bootstrap a fresh host. + +name: Deploy to VPS + +on: + workflow_dispatch: + push: + # NOTE: change this to `main` once the deploy branch is merged. + branches: + - claude/nvidia-ai-models-access-mmn5n5 + paths: + - "apps/**" + - "deploy/**" + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + # Secrets can't be referenced in a job-level `if:`, so we gate here: + # map the secret into an env var and skip the rest of the job when empty. + # This keeps the workflow inert (green, no-op) until secrets are added. + - name: Check that deploy secrets are configured + id: gate + env: + VPS_HOST: ${{ secrets.VPS_HOST }} + run: | + if [ -z "$VPS_HOST" ]; then + echo "VPS_HOST secret is not set — skipping deploy (this is expected until you add secrets)." + echo "configured=false" >> "$GITHUB_OUTPUT" + else + echo "configured=true" >> "$GITHUB_OUTPUT" + fi + + - name: Deploy over SSH + if: steps.gate.outputs.configured == 'true' + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + # appleboy/ssh-action defaults port to 22 when this is empty. + port: ${{ secrets.VPS_PORT }} + script: | + set -e + cd ~/allerion + git fetch + git checkout claude/nvidia-ai-models-access-mmn5n5 + git pull --ff-only + cd deploy + docker compose up -d --build diff --git a/apps/agent-market/README.md b/apps/agent-market/README.md new file mode 100644 index 0000000..d881184 --- /dev/null +++ b/apps/agent-market/README.md @@ -0,0 +1,74 @@ +# Allerion Agent Market + +A functional **agent-to-agent (A2A) commerce** simulation: AI **merchant agents** +sell services (summarize, translate, enrich, copywrite) to AI **buyer agents**. +Buyers discover suppliers, **negotiate price** through alternating offers, +transact through a **settlement ledger**, and the platform takes a **commission +on every deal** — that's the revenue model. + +It runs with **zero dependencies and no API key** (deterministic fulfilment). +Point it at any **OpenAI-compatible endpoint** — the Allerion gateway, OpenRouter, +NVIDIA, OpenAI — to fulfil purchased work with a real model. + +## Run it + +```bash +cd apps/agent-market + +# Offline, deterministic — no key needed: +python run_demo.py + +# Live fulfilment via a real model (example: OpenRouter): +LLM_BASE_URL=https://openrouter.ai/api \ +LLM_API_KEY=sk-or-... \ +LLM_MODEL=openai/gpt-4o-mini \ +python run_demo.py + +# Live via the Allerion gateway (PR #2): +LLM_BASE_URL=https://api.allerion.io LLM_API_KEY=sk-... LLM_MODEL=deepseek-r1 python run_demo.py +``` + +## Tests + +```bash +pip install pytest +pytest -q +``` + +## How it works + +``` +buyer.need ─discover→ marketplace ─quotes→ negotiate (alternating offers) + │ deal price within [floor, value] + ▼ + ledger.settle ──fee──> platform wallet + │ net + ▼ + merchant wallet ; llm.fulfill() delivers work +``` + +| Module | Responsibility | +|--------|----------------| +| `protocol.py` | `Service`, `Need`, `Deal`, A2A `Message` types | +| `agents.py` | `MerchantAgent` (quoting, concession) and `BuyerAgent` (ranking, bidding) | +| `marketplace.py` | registry, capability discovery, the negotiation engine | +| `ledger.py` | wallets, settlement, platform commission, money-conservation invariant | +| `llm.py` | service fulfilment — real model or deterministic stub | +| `market.py` | the round loop + analytics (GMV, revenue, P&L) | +| `run_demo.py` | seeds an economy and prints transcript + ledger | + +## Economic model +- Each service has a `list_price` (opening ask) and a `floor_price` (walk-away). +- Each buyer need has a `value` (max willingness to pay). +- A deal closes only when the bargaining range overlaps (`floor ≤ value`), at a + price in between — so margins and outcomes vary by agent strategy. +- **Money is conserved**: every settlement moves funds buyer → merchant + a + commission to the platform. `summary()["money_conserved"]` must stay `0.00` + (opening balances are funded from a mint account that nets out). + +## Going further +- Swap deterministic agent policies for **LLM-driven negotiation** (have each + agent reason about its next offer via `llm.fulfill`). +- Wire settlement to the **Stripe metered billing** in `infra/ai-gateway/billing` + so agent spend becomes real invoices. +- Expose the marketplace over HTTP so external agents can register and trade. diff --git a/apps/agent-market/agentmarket/__init__.py b/apps/agent-market/agentmarket/__init__.py new file mode 100644 index 0000000..4031323 --- /dev/null +++ b/apps/agent-market/agentmarket/__init__.py @@ -0,0 +1,11 @@ +"""Allerion Agent Market — agent-to-agent (A2A) commerce. + +AI merchant agents sell services to AI buyer agents. Buyers discover +suppliers, negotiate price, transact through a settlement ledger, and the +platform takes a commission on every deal. The whole simulation runs with +zero external dependencies; service fulfilment optionally calls a real +OpenAI-compatible endpoint (e.g. the Allerion gateway) when configured. +""" + +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/apps/agent-market/agentmarket/agents.py b/apps/agent-market/agentmarket/agents.py new file mode 100644 index 0000000..713f675 --- /dev/null +++ b/apps/agent-market/agentmarket/agents.py @@ -0,0 +1,65 @@ +"""The agents: merchants that sell services and buyers that procure them.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, List, Optional + +from .protocol import Need, Service + + +@dataclass +class MerchantAgent: + id: str + name: str + services: List[Service] = field(default_factory=list) + concession: float = 0.4 # how fast it moves from ask toward floor (0..1) + sales: int = 0 + connector: Optional[Any] = None # gateway connector (see connector.Connector) + + def offer(self, service: Service) -> "MerchantAgent": + self.services.append(service) + return self + + def quote(self, service: Service) -> float: + return service.list_price + + def counter(self, service: Service, current_ask: float, buyer_bid: float) -> float: + """Concede toward the buyer, but never below the service floor.""" + target = max(service.floor_price, current_ask - (current_ask - service.floor_price) * self.concession) + # If the buyer's bid already clears the floor, meet in the middle. + if buyer_bid >= service.floor_price: + return round(max(service.floor_price, min(target, (target + buyer_bid) / 2)), 2) + return round(target, 2) + + +@dataclass +class BuyerAgent: + id: str + name: str + needs: List[Need] = field(default_factory=list) + quality_weight: float = 0.5 # how much it trades price for quality + concession: float = 0.4 + purchases: int = 0 + connector: Optional[Any] = None # gateway connector (see connector.Connector) + + def score(self, need: Need, service: Service) -> float: + """Expected utility of a supplier before negotiating: value adjusted + for quality, minus the opening ask. Higher is better.""" + perceived_value = need.value * (0.5 + self.quality_weight * service.quality) + return perceived_value - service.list_price + + def rank(self, need: Need, services: List[Service]) -> List[Service]: + return sorted(services, key=lambda s: self.score(need, s), reverse=True) + + def opening_bid(self, need: Need, service: Service) -> float: + # Lowball but never above what the need is worth. + return round(min(need.value, service.list_price * 0.6), 2) + + def raise_bid(self, need: Need, current_bid: float) -> float: + return round(min(need.value, current_bid + (need.value - current_bid) * self.concession), 2) + + def accepts(self, need: Need, price: float) -> bool: + return price <= need.value + + def next_need(self) -> Optional[Need]: + return self.needs.pop(0) if self.needs else None diff --git a/apps/agent-market/agentmarket/connector.py b/apps/agent-market/agentmarket/connector.py new file mode 100644 index 0000000..5c9d02f --- /dev/null +++ b/apps/agent-market/agentmarket/connector.py @@ -0,0 +1,44 @@ +"""The Allerion gateway connector — embedded into every agent. + +A Connector is an agent's handle to the Allerion AI gateway (the +OpenAI-compatible endpoint from infra/ai-gateway). Each agent carries its own +Connector, so fulfilment and reasoning route through the shared gateway — +metered, rate-limited, and billable per agent. When no gateway/key is +configured the connector falls back to deterministic stubs, so a whole fleet +runs offline with zero credentials. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from . import llm + + +@dataclass +class Connector: + """Per-agent connection to the gateway.""" + + agent_id: str + model: str = llm.MODEL + calls: int = 0 + + @property + def live(self) -> bool: + return llm.live() + + def status(self) -> str: + return f"live:{self.model}" if self.live else "stub" + + def fulfill(self, capability: str, prompt: str) -> str: + """Route a unit of work through the gateway on this agent's behalf.""" + self.calls += 1 + return llm.fulfill(capability, prompt) + + +def attach(agents) -> int: + """Embed a fresh Connector into each agent. Returns the count wired.""" + n = 0 + for a in agents: + a.connector = Connector(agent_id=a.id) + n += 1 + return n diff --git a/apps/agent-market/agentmarket/ledger.py b/apps/agent-market/agentmarket/ledger.py new file mode 100644 index 0000000..d955d21 --- /dev/null +++ b/apps/agent-market/agentmarket/ledger.py @@ -0,0 +1,74 @@ +"""Settlement ledger: wallets, transactions, and the platform's cut. + +Money is conserved: every settlement moves funds from a buyer to a merchant +and a commission to the platform (house) wallet — no money is created or +destroyed. `total_money()` is the invariant the tests assert on. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List + +from .protocol import Deal + + +class InsufficientFunds(Exception): + pass + + +@dataclass +class Entry: + round: int + debit: str # who paid + credit: str # who received + amount: float + memo: str + + +@dataclass +class Ledger: + house_id: str = "allerion" + fee_rate: float = 0.10 # platform commission on every deal + balances: Dict[str, float] = field(default_factory=dict) + entries: List[Entry] = field(default_factory=list) + + def open_account(self, agent_id: str, opening_balance: float = 0.0) -> None: + self.balances.setdefault(agent_id, 0.0) + if opening_balance: + # Opening balances are funded from a mint account so the live + # economy still conserves money after seeding. + self.balances[agent_id] += opening_balance + self.balances.setdefault("__mint__", 0.0) + self.balances["__mint__"] -= opening_balance + + def balance(self, agent_id: str) -> float: + return self.balances.get(agent_id, 0.0) + + def settle(self, deal: Deal) -> Deal: + """Move `deal.price` buyer→merchant, skimming `fee_rate` to the house.""" + if self.balance(deal.buyer_id) < deal.price: + raise InsufficientFunds( + f"{deal.buyer_id} has ${self.balance(deal.buyer_id):,.2f}, " + f"needs ${deal.price:,.2f}" + ) + fee = round(deal.price * self.fee_rate, 2) + net = round(deal.price - fee, 2) + + self.balances[deal.buyer_id] -= deal.price + self.balances.setdefault(deal.merchant_id, 0.0) + self.balances[deal.merchant_id] += net + self.balances.setdefault(self.house_id, 0.0) + self.balances[self.house_id] += fee + + self.entries.append(Entry(deal.round, deal.buyer_id, deal.merchant_id, net, + f"{deal.capability} ({deal.service_id})")) + self.entries.append(Entry(deal.round, deal.buyer_id, self.house_id, fee, + f"commission {self.fee_rate:.0%}")) + deal.fee = fee + return deal + + def total_money(self) -> float: + return round(sum(self.balances.values()), 2) + + def platform_revenue(self) -> float: + return round(self.balance(self.house_id), 2) diff --git a/apps/agent-market/agentmarket/llm.py b/apps/agent-market/agentmarket/llm.py new file mode 100644 index 0000000..e42ce10 --- /dev/null +++ b/apps/agent-market/agentmarket/llm.py @@ -0,0 +1,58 @@ +"""Service fulfilment. + +If LLM_BASE_URL and LLM_API_KEY are set, fulfilment calls a real +OpenAI-compatible endpoint (point it at the Allerion gateway, OpenRouter, +NVIDIA, OpenAI, etc.). Otherwise it returns a deterministic stub so the whole +market runs offline with no keys. +""" +from __future__ import annotations + +import os +import textwrap + +BASE_URL = os.environ.get("LLM_BASE_URL", "").rstrip("/") +API_KEY = os.environ.get("LLM_API_KEY", "") +MODEL = os.environ.get("LLM_MODEL", "deepseek-r1") + + +def live() -> bool: + return bool(BASE_URL and API_KEY) + + +def _stub(capability: str, prompt: str) -> str: + body = prompt or f"(no prompt supplied for {capability})" + return textwrap.shorten( + f"[stub:{capability}] delivered work for → {body}", width=160, placeholder=" …" + ) + + +def fulfill(capability: str, prompt: str) -> str: + """Return the work product for one purchased service unit.""" + if not live(): + return _stub(capability, prompt) + + # Use the standard library so live mode needs no extra dependency. + import json + import urllib.request + + system = f"You are a specialist agent providing the '{capability}' service. Be concise." + payload = json.dumps({ + "model": MODEL, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": prompt or capability}, + ], + "max_tokens": 120, + }).encode() + req = urllib.request.Request( + f"{BASE_URL}/v1/chat/completions", + data=payload, + headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read()) + return data["choices"][0]["message"]["content"].strip() + except Exception as e: # noqa: BLE001 — never let fulfilment crash the market + return f"[fulfilment error via {MODEL}: {e}]" diff --git a/apps/agent-market/agentmarket/market.py b/apps/agent-market/agentmarket/market.py new file mode 100644 index 0000000..f6a72ae --- /dev/null +++ b/apps/agent-market/agentmarket/market.py @@ -0,0 +1,102 @@ +"""The market loop: runs rounds where buyers procure from merchants.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List + +from . import llm +from .agents import BuyerAgent, MerchantAgent +from .ledger import InsufficientFunds, Ledger +from .marketplace import Marketplace +from .protocol import Deal, Message + + +@dataclass +class RoundReport: + round: int + deals: List[Deal] = field(default_factory=list) + transcript: List[Message] = field(default_factory=list) + no_deals: int = 0 + + +@dataclass +class Market: + marketplace: Marketplace + ledger: Ledger + buyers: Dict[str, BuyerAgent] = field(default_factory=dict) + reports: List[RoundReport] = field(default_factory=list) + + def add_buyer(self, buyer: BuyerAgent, opening_balance: float) -> None: + self.buyers[buyer.id] = buyer + self.ledger.open_account(buyer.id, opening_balance) + + def _ensure_accounts(self) -> None: + for mid in self.marketplace.merchants: + self.ledger.open_account(mid) + self.ledger.open_account(self.ledger.house_id) + + def run_round(self, round_no: int, fulfill: bool = True) -> RoundReport: + self._ensure_accounts() + self.marketplace.reset_round() + report = RoundReport(round=round_no) + + for buyer in self.buyers.values(): + need = buyer.next_need() + if need is None: + continue + + suppliers = self.marketplace.discover(need.capability) + if not suppliers: + report.no_deals += 1 + continue + + closed = False + for service in buyer.rank(need, suppliers): + if self.marketplace._remaining_capacity.get(service.id, 0) <= 0: + continue + price, n, transcript = self.marketplace.negotiate(buyer, need, service) + report.transcript.extend(transcript) + if price is None: + continue + if self.ledger.balance(buyer.id) < price: + continue # can't afford; try next supplier + + deal = Deal(round_no, buyer.id, service.merchant_id, service.id, + need.capability, price, n) + try: + self.ledger.settle(deal) + except InsufficientFunds: + continue + + self.marketplace.consume_capacity(service) + self.marketplace.merchants[service.merchant_id].sales += 1 + buyer.purchases += 1 + if fulfill: + deal.delivered = llm.fulfill(need.capability, need.prompt) + report.deals.append(deal) + closed = True + break + + if not closed: + report.no_deals += 1 + + self.reports.append(report) + return report + + def run(self, rounds: int, fulfill: bool = True) -> List[RoundReport]: + return [self.run_round(r, fulfill=fulfill) for r in range(1, rounds + 1)] + + # --- analytics --------------------------------------------------------- + def all_deals(self) -> List[Deal]: + return [d for rep in self.reports for d in rep.deals] + + def summary(self) -> dict: + deals = self.all_deals() + gmv = round(sum(d.price for d in deals), 2) + return { + "deals": len(deals), + "gmv": gmv, + "platform_revenue": self.ledger.platform_revenue(), + "avg_price": round(gmv / len(deals), 2) if deals else 0.0, + "money_conserved": self.ledger.total_money(), + } diff --git a/apps/agent-market/agentmarket/marketplace.py b/apps/agent-market/agentmarket/marketplace.py new file mode 100644 index 0000000..b09dcda --- /dev/null +++ b/apps/agent-market/agentmarket/marketplace.py @@ -0,0 +1,78 @@ +"""The marketplace: a registry, discovery, and the negotiation engine.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + +from .agents import BuyerAgent, MerchantAgent +from .protocol import Deal, Message, MessageType, Need, Service + +MAX_NEGOTIATION_ROUNDS = 6 + + +@dataclass +class Marketplace: + merchants: Dict[str, MerchantAgent] = field(default_factory=dict) + services: List[Service] = field(default_factory=list) + _remaining_capacity: Dict[str, int] = field(default_factory=dict) + + def register(self, merchant: MerchantAgent) -> None: + self.merchants[merchant.id] = merchant + for svc in merchant.services: + self.services.append(svc) + + def reset_round(self) -> None: + self._remaining_capacity = {s.id: s.capacity_per_round for s in self.services} + + def discover(self, capability: str) -> List[Service]: + """Suppliers for a capability that still have capacity this round.""" + return [ + s for s in self.services + if s.capability == capability and self._remaining_capacity.get(s.id, 0) > 0 + ] + + def negotiate( + self, buyer: BuyerAgent, need: Need, service: Service + ) -> Tuple[Optional[float], int, List[Message]]: + """Alternating-offer bargaining. Returns (agreed_price | None, rounds, transcript).""" + merchant = self.merchants[service.merchant_id] + transcript: List[Message] = [ + Message(MessageType.DISCOVER, buyer.id, service.merchant_id, need.capability) + ] + + ask = merchant.quote(service) + transcript.append(Message(MessageType.QUOTE, service.merchant_id, buyer.id, need.capability, ask)) + + # Impossible deal: merchant's floor is above what the need is worth. + if service.floor_price > need.value: + transcript.append(Message(MessageType.REJECT, buyer.id, service.merchant_id, need.capability)) + return None, 0, transcript + + bid = buyer.opening_bid(need, service) + transcript.append(Message(MessageType.OFFER, buyer.id, service.merchant_id, need.capability, bid)) + + for r in range(1, MAX_NEGOTIATION_ROUNDS + 1): + if bid >= ask: # buyer's bid meets the ask → done at the ask + transcript.append(Message(MessageType.ACCEPT, buyer.id, service.merchant_id, need.capability, ask)) + return round(ask, 2), r, transcript + + ask = merchant.counter(service, ask, bid) + transcript.append(Message(MessageType.COUNTER, service.merchant_id, buyer.id, need.capability, ask)) + + if bid >= ask and buyer.accepts(need, ask): + transcript.append(Message(MessageType.ACCEPT, buyer.id, service.merchant_id, need.capability, ask)) + return round(ask, 2), r, transcript + + bid = buyer.raise_bid(need, bid) + transcript.append(Message(MessageType.OFFER, buyer.id, service.merchant_id, need.capability, bid)) + + if bid >= ask and buyer.accepts(need, ask): + price = round((ask + bid) / 2, 2) + transcript.append(Message(MessageType.ACCEPT, buyer.id, service.merchant_id, need.capability, price)) + return price, r, transcript + + transcript.append(Message(MessageType.REJECT, buyer.id, service.merchant_id, need.capability)) + return None, MAX_NEGOTIATION_ROUNDS, transcript + + def consume_capacity(self, service: Service) -> None: + self._remaining_capacity[service.id] -= 1 diff --git a/apps/agent-market/agentmarket/protocol.py b/apps/agent-market/agentmarket/protocol.py new file mode 100644 index 0000000..e70a1c7 --- /dev/null +++ b/apps/agent-market/agentmarket/protocol.py @@ -0,0 +1,79 @@ +"""A2A protocol primitives: the things agents offer, want, and exchange.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + + +class MessageType(str, Enum): + DISCOVER = "discover" + QUOTE = "quote" + OFFER = "offer" + COUNTER = "counter" + ACCEPT = "accept" + REJECT = "reject" + DELIVER = "deliver" + SETTLE = "settle" + + +@dataclass(frozen=True) +class Service: + """A capability a merchant agent sells, with its pricing envelope.""" + + id: str + name: str + capability: str # the tag buyers search on, e.g. "summarize" + merchant_id: str + list_price: float # opening ask + floor_price: float # walk-away minimum + quality: float # 0..1, drives buyer's perceived value + capacity_per_round: int # how many deals it can fulfil per round + + def __post_init__(self) -> None: + if self.floor_price > self.list_price: + raise ValueError(f"{self.id}: floor_price exceeds list_price") + if not 0.0 <= self.quality <= 1.0: + raise ValueError(f"{self.id}: quality must be in [0,1]") + + +@dataclass(frozen=True) +class Need: + """Something a buyer agent wants done, with its max willingness to pay.""" + + id: str + buyer_id: str + capability: str + value: float # the most this buyer will pay for one unit + prompt: str = "" # passed to fulfilment + + +@dataclass +class Deal: + """A closed transaction between a buyer and a merchant.""" + + round: int + buyer_id: str + merchant_id: str + service_id: str + capability: str + price: float + rounds_negotiated: int + delivered: Optional[str] = None + fee: float = 0.0 + + +@dataclass +class Message: + """An A2A message exchanged during negotiation (kept for the transcript).""" + + mtype: MessageType + sender: str + recipient: str + capability: str + price: Optional[float] = None + meta: dict = field(default_factory=dict) + + def render(self) -> str: + amount = f" ${self.price:,.2f}" if self.price is not None else "" + return f"{self.sender:>12} →{self.recipient:>12} {self.mtype.value.upper():<8}{amount} [{self.capability}]" diff --git a/apps/agent-market/requirements.txt b/apps/agent-market/requirements.txt new file mode 100644 index 0000000..82e62a0 --- /dev/null +++ b/apps/agent-market/requirements.txt @@ -0,0 +1,5 @@ +# The app — offline AND live fulfilment — runs on the Python standard library +# alone. No runtime dependencies. +# +# Dev only: +pytest>=8.0 diff --git a/apps/agent-market/run_demo.py b/apps/agent-market/run_demo.py new file mode 100644 index 0000000..524dfd3 --- /dev/null +++ b/apps/agent-market/run_demo.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Allerion Agent Market — runnable demo. + +Seeds a small economy of merchant and buyer agents, runs several trading +rounds, and prints the negotiation transcript, the settlement ledger, and the +platform's take. Runs with no API key (deterministic fulfilment); set +LLM_BASE_URL + LLM_API_KEY to fulfil purchased work with a real model. + + python run_demo.py # offline, deterministic + LLM_BASE_URL=https://api.allerion.io LLM_API_KEY=sk-... python run_demo.py +""" +from __future__ import annotations + +import random + +from agentmarket import llm +from agentmarket.agents import BuyerAgent, MerchantAgent +from agentmarket.ledger import Ledger +from agentmarket.market import Market +from agentmarket.marketplace import Marketplace +from agentmarket.protocol import Need, Service + +ROUNDS = 5 +SEED = 7 + + +def build_marketplace() -> Marketplace: + mp = Marketplace() + scribe = MerchantAgent("scribe", "Scribe.ai", concession=0.35) + scribe.offer(Service("scribe-sum", "Premium Summaries", "summarize", "scribe", 10, 6, 0.90, 3)) + scribe.offer(Service("scribe-copy", "Brand Copy", "copywrite", "scribe", 14, 9, 0.85, 2)) + + lingua = MerchantAgent("lingua", "Lingua.ai", concession=0.5) + lingua.offer(Service("lingua-tr", "Fast Translate", "translate", "lingua", 8, 5, 0.80, 4)) + lingua.offer(Service("lingua-sum", "Budget Summaries", "summarize", "lingua", 7, 4, 0.60, 3)) + + forge = MerchantAgent("forge", "DataForge", concession=0.3) + forge.offer(Service("forge-en", "Deep Enrichment", "enrich", "forge", 20, 12, 0.95, 2)) + forge.offer(Service("forge-tr", "Technical Translate", "translate", "forge", 9, 6, 0.70, 2)) + + quill = MerchantAgent("quill", "Quill", concession=0.45) + quill.offer(Service("quill-copy", "Value Copy", "copywrite", "quill", 11, 7, 0.70, 3)) + quill.offer(Service("quill-sum", "Standard Summaries", "summarize", "quill", 9, 5, 0.75, 2)) + + for m in (scribe, lingua, forge, quill): + mp.register(m) + return mp + + +def needs_for(buyer_id: str, templates, rounds: int): + out = [] + for r in range(rounds): + cap, value, prompt = templates[r % len(templates)] + out.append(Need(f"{buyer_id}-n{r}", buyer_id, cap, value, prompt)) + return out + + +def build_market() -> Market: + mp = build_marketplace() + ledger = Ledger(house_id="allerion", fee_rate=0.10) + market = Market(marketplace=mp, ledger=ledger) + + atlas = BuyerAgent("atlas", "Atlas Corp", quality_weight=0.7, concession=0.4) + atlas.needs = needs_for("atlas", [ + ("enrich", 25, "enrich 500 B2B leads with firmographics"), + ("summarize", 12, "summarize the Q2 earnings call"), + ("copywrite", 16, "write a launch announcement for our API"), + ], ROUNDS) + + nimbus = BuyerAgent("nimbus", "Nimbus Labs", quality_weight=0.3, concession=0.45) + nimbus.needs = needs_for("nimbus", [ + ("summarize", 10, "tl;dr this support thread"), + ("translate", 9, "translate docs to Spanish"), + ], ROUNDS) + + orbit = BuyerAgent("orbit", "Orbit Retail", quality_weight=0.5, concession=0.4) + orbit.needs = needs_for("orbit", [ + ("translate", 10, "translate product listings to German"), + ("copywrite", 15, "write 20 product descriptions"), + ("enrich", 22, "enrich our customer table with industry codes"), + ], ROUNDS) + + market.add_buyer(atlas, opening_balance=200.0) + market.add_buyer(nimbus, opening_balance=120.0) + market.add_buyer(orbit, opening_balance=150.0) + return market + + +def hr(char="─", n=72): + print(char * n) + + +def main() -> None: + random.seed(SEED) + market = build_market() + + print() + hr("═") + print(" ALLERION AGENT MARKET — agent-to-agent commerce") + print(f" fulfilment: {'LIVE via ' + llm.MODEL if llm.live() else 'deterministic stub (no API key)'}") + print(f" platform commission: {market.ledger.fee_rate:.0%} rounds: {ROUNDS}") + hr("═") + + reports = market.run(ROUNDS, fulfill=True) + + print("\n Sample negotiation transcript (round 1):") + hr() + for msg in reports[0].transcript[:14]: + print(" " + msg.render()) + hr() + + print("\n Per-round activity:") + for rep in reports: + gmv = sum(d.price for d in rep.deals) + print(f" round {rep.round}: {len(rep.deals)} deals · ${gmv:6.2f} GMV · {rep.no_deals} unmatched") + + print("\n Closed deals:") + hr() + for d in market.all_deals(): + print(f" r{d.round} {d.buyer_id:>7} → {d.merchant_id:<7} {d.capability:<10} " + f"${d.price:6.2f} (fee ${d.fee:.2f}, {d.rounds_negotiated} rounds)") + hr() + + print("\n Final balances (wallets):") + for aid, bal in sorted(market.ledger.balances.items(), key=lambda kv: -kv[1]): + if aid == "__mint__": + continue + tag = " ← platform" if aid == market.ledger.house_id else "" + print(f" {aid:>10}: ${bal:8.2f}{tag}") + + s = market.summary() + print("\n Summary:") + hr() + print(f" deals closed : {s['deals']}") + print(f" GMV : ${s['gmv']:.2f}") + print(f" platform revenue : ${s['platform_revenue']:.2f} ({market.ledger.fee_rate:.0%} of GMV)") + print(f" avg deal price : ${s['avg_price']:.2f}") + print(f" money conserved : ${s['money_conserved']:.2f} (sum of all wallets incl. mint == 0.00)") + hr() + + if market.all_deals() and market.all_deals()[0].delivered: + print("\n Sample delivered work product:") + print(" " + market.all_deals()[0].delivered) + print() + + +if __name__ == "__main__": + main() diff --git a/apps/agent-market/run_fleet.py b/apps/agent-market/run_fleet.py new file mode 100644 index 0000000..ef27e10 --- /dev/null +++ b/apps/agent-market/run_fleet.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Allerion Agent Market — 80-agent fleet on the gateway connector. + +Spins up 80 agents (30 merchants + 50 buyers), embeds a gateway Connector into +each one, runs the A2A market, and fulfils every closed deal through the +merchant's connector. Demonstrates "the connector embedded into 80 agents": +all 80 route work through the shared Allerion gateway (live with a key, stub +without). + + python3 run_fleet.py + LLM_BASE_URL=https://openrouter.ai/api LLM_API_KEY=sk-or-... \ + LLM_MODEL=openai/gpt-4o-mini python3 run_fleet.py +""" +from __future__ import annotations + +import random + +from agentmarket import connector, llm +from agentmarket.agents import BuyerAgent, MerchantAgent +from agentmarket.ledger import Ledger +from agentmarket.market import Market +from agentmarket.marketplace import Marketplace +from agentmarket.protocol import Need, Service + +N_MERCHANTS = 30 +N_BUYERS = 50 +ROUNDS = 4 +SEED = 11 + +CAPS = ["summarize", "translate", "enrich", "copywrite", "classify", "extract"] +PROMPTS = { + "summarize": "summarize this quarter's field report", + "translate": "translate the spec sheet to German", + "enrich": "enrich 200 supplier records with firmographics", + "copywrite": "write launch copy for the new module", + "classify": "classify these 500 support tickets", + "extract": "extract line items from these invoices", +} + + +def build_fleet(): + rng = random.Random(SEED) + mp = Marketplace() + merchants = [] + for i in range(N_MERCHANTS): + m = MerchantAgent(f"m{i:02d}", f"Vendor-{i:02d}", concession=rng.uniform(0.3, 0.55)) + # each merchant offers 1-2 capabilities + for cap in rng.sample(CAPS, k=rng.randint(1, 2)): + base = rng.uniform(6, 22) + m.offer(Service( + id=f"{m.id}-{cap}", name=f"{cap} by {m.name}", capability=cap, + merchant_id=m.id, list_price=round(base, 2), + floor_price=round(base * rng.uniform(0.5, 0.7), 2), + quality=round(rng.uniform(0.55, 0.97), 2), + capacity_per_round=rng.randint(2, 6), + )) + mp.register(m) + merchants.append(m) + + ledger = Ledger(house_id="allerion", fee_rate=0.10) + market = Market(marketplace=mp, ledger=ledger) + buyers = [] + for i in range(N_BUYERS): + b = BuyerAgent(f"b{i:02d}", f"Buyer-{i:02d}", + quality_weight=rng.uniform(0.2, 0.8), concession=rng.uniform(0.3, 0.5)) + b.needs = [ + Need(f"{b.id}-n{r}", b.id, (cap := rng.choice(CAPS)), + value=round(rng.uniform(8, 26), 2), prompt=PROMPTS[cap]) + for r in range(ROUNDS) + ] + market.add_buyer(b, opening_balance=round(rng.uniform(120, 320), 2)) + buyers.append(b) + + # Embed the gateway connector into every agent — this is the "80 agents". + wired = connector.attach(merchants) + connector.attach(buyers) + return market, merchants, buyers, wired + + +def main() -> None: + random.seed(SEED) + market, merchants, buyers, wired = build_fleet() + + print() + print("=" * 70) + print(" ALLERION FLEET — agent-to-agent commerce on the gateway connector") + print(f" agents wired to connector: {wired} ({len(merchants)} merchants + {len(buyers)} buyers)") + print(f" gateway: {'LIVE via ' + llm.MODEL if llm.live() else 'deterministic stub (no key)'}") + print("=" * 70) + + # Run the market WITHOUT auto-fulfil; we fulfil via each merchant's connector. + market.run(ROUNDS, fulfill=False) + deals = market.all_deals() + + # Fulfil each closed deal through the selling merchant's embedded connector. + by_id = {m.id: m for m in merchants} + for d in deals: + conn = by_id[d.merchant_id].connector + d.delivered = conn.fulfill(d.capability, PROMPTS.get(d.capability, d.capability)) + + s = market.summary() + active = sum(1 for m in merchants if m.connector.calls) + \ + sum(1 for b in buyers if b.purchases) + total_calls = sum(m.connector.calls for m in merchants) + + print(f"\n rounds: {ROUNDS} deals: {s['deals']} GMV: ${s['gmv']:.2f}") + print(f" platform revenue: ${s['platform_revenue']:.2f} (10% of GMV)") + print(f" connector calls through gateway: {total_calls}") + print(f" agents that transacted: {active}/{wired}") + print(f" money conserved: ${s['money_conserved']:.2f}") + + print("\n Top 5 merchants by gateway calls:") + for m in sorted(merchants, key=lambda x: -x.connector.calls)[:5]: + print(f" {m.id} {m.name:<11} {m.connector.status():<22} calls={m.connector.calls} sales={m.sales}") + + if deals and deals[0].delivered: + print("\n Sample delivered work (via connector):") + print(" " + deals[0].delivered[:160]) + print() + + +if __name__ == "__main__": + main() diff --git a/apps/agent-market/tests/test_market.py b/apps/agent-market/tests/test_market.py new file mode 100644 index 0000000..a3579a8 --- /dev/null +++ b/apps/agent-market/tests/test_market.py @@ -0,0 +1,98 @@ +"""Tests for the agent market: money conservation, settlement, negotiation.""" +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from agentmarket.agents import BuyerAgent, MerchantAgent +from agentmarket.ledger import InsufficientFunds, Ledger +from agentmarket.marketplace import Marketplace +from agentmarket.market import Market +from agentmarket.protocol import Deal, Need, Service + + +def make_service(**kw): + base = dict(id="s1", name="S", capability="summarize", merchant_id="m1", + list_price=10, floor_price=6, quality=0.8, capacity_per_round=2) + base.update(kw) + return Service(**base) + + +def test_service_rejects_bad_envelope(): + with pytest.raises(ValueError): + make_service(list_price=5, floor_price=9) + with pytest.raises(ValueError): + make_service(quality=1.5) + + +def test_ledger_conserves_money(): + led = Ledger(fee_rate=0.1) + led.open_account("buyer", 100) + led.open_account("m1") + assert led.total_money() == 0.0 # mint offsets opening balances + led.settle(Deal(1, "buyer", "m1", "s1", "summarize", 10, 1)) + assert led.total_money() == 0.0 # still conserved after a trade + + +def test_settlement_splits_fee(): + led = Ledger(fee_rate=0.1) + led.open_account("buyer", 100) + led.open_account("m1") + led.settle(Deal(1, "buyer", "m1", "s1", "summarize", 10, 1)) + assert led.balance("buyer") == 90.0 + assert led.balance("m1") == 9.0 # 10 - 10% fee + assert led.platform_revenue() == 1.0 + + +def test_insufficient_funds_blocks_settlement(): + led = Ledger() + led.open_account("buyer", 5) + led.open_account("m1") + with pytest.raises(InsufficientFunds): + led.settle(Deal(1, "buyer", "m1", "s1", "summarize", 10, 1)) + + +def test_negotiation_closes_when_overlap_exists(): + mp = Marketplace() + m = MerchantAgent("m1", "M").offer(make_service(list_price=10, floor_price=6)) + mp.register(m) + mp.reset_round() + buyer = BuyerAgent("b1", "B") + need = Need("n1", "b1", "summarize", value=12) + price, rounds, transcript = mp.negotiate(buyer, need, mp.services[0]) + assert price is not None + assert 6 <= price <= 12 + assert rounds >= 1 + + +def test_negotiation_fails_when_floor_above_value(): + mp = Marketplace() + m = MerchantAgent("m1", "M").offer(make_service(list_price=20, floor_price=15)) + mp.register(m) + mp.reset_round() + buyer = BuyerAgent("b1", "B") + need = Need("n1", "b1", "summarize", value=10) # worth less than the floor + price, _, _ = mp.negotiate(buyer, need, mp.services[0]) + assert price is None + + +def test_full_market_run_conserves_and_trades(): + mp = Marketplace() + seller = MerchantAgent("m1", "M", concession=0.4) + seller.offer(make_service(capacity_per_round=5)) + mp.register(seller) + market = Market(marketplace=mp, ledger=Ledger(fee_rate=0.1)) + buyer = BuyerAgent("b1", "B") + buyer.needs = [Need(f"n{i}", "b1", "summarize", 12) for i in range(3)] + market.add_buyer(buyer, opening_balance=100) + + market.run(3, fulfill=True) + + summary = market.summary() + assert summary["deals"] >= 1 + assert summary["money_conserved"] == 0.0 + assert summary["platform_revenue"] > 0 + # GMV must equal what left the buyer's wallet. + assert round(100 - market.ledger.balance("b1"), 2) == summary["gmv"] diff --git a/apps/platform/.gitignore b/apps/platform/.gitignore new file mode 100644 index 0000000..f818582 --- /dev/null +++ b/apps/platform/.gitignore @@ -0,0 +1,4 @@ +crm.db +*.db +__pycache__/ +*.pyc diff --git a/apps/platform/Dockerfile b/apps/platform/Dockerfile new file mode 100644 index 0000000..ce58b1e --- /dev/null +++ b/apps/platform/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim +WORKDIR /app +# Stdlib-only app — no pip install needed. +COPY server.py billing.py ./ +ENV ALLERION_CRM_DB=/data/crm.db +EXPOSE 8099 +CMD ["python3", "server.py", "--host", "0.0.0.0", "--port", "8099"] diff --git a/apps/platform/README.md b/apps/platform/README.md new file mode 100644 index 0000000..161d1f9 --- /dev/null +++ b/apps/platform/README.md @@ -0,0 +1,55 @@ +# Allerion Platform — marketing site + embedded CRM + +A single, zero-dependency app (Python standard library only) that serves the +Allerion **marketing website** and an **embedded CRM** over one SQLite store. +Access requests on the landing page become leads; the CRM tracks them through a +pipeline. + +## Run + +```bash +cd apps/platform +python3 server.py # http://127.0.0.1:8099 +python3 server.py --port 9000 --host 0.0.0.0 +``` + +No `pip install` — it uses only `http.server` + `sqlite3`. + +## Routes + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/` | Marketing landing page (capabilities, pricing, access form) | +| POST | `/api/leads` | Create a lead (HTML form post → redirects to `/crm`; JSON → returns the lead) | +| GET | `/crm` | CRM dashboard: pipeline KPIs + lead table with inline status updates | +| GET | `/api/leads` | List leads as JSON | +| POST | `/api/leads//status` | Advance a lead's pipeline status (`new → contacted → qualified → won/lost`) | +| GET | `/healthz` | Health check | + +## Examples + +```bash +# Capture a lead via the JSON API +curl -X POST http://127.0.0.1:8099/api/leads -H 'Content-Type: application/json' \ + -d '{"name":"Jane","email":"jane@acme.co","company":"Acme","interest":"AI Gateway"}' + +# Move it through the pipeline +curl -X POST http://127.0.0.1:8099/api/leads/1/status \ + -H 'Content-Type: application/json' -d '{"status":"qualified"}' + +# Read the CRM +curl http://127.0.0.1:8099/api/leads +``` + +## Data + +SQLite at `apps/platform/crm.db` (git-ignored), overridable via `ALLERION_CRM_DB`. +The `leads` table is created automatically on first run. + +## How it fits the platform + +This is the customer-facing front of the Allerion stack: the **landing page** +sells the AI Gateway / Agent Orchestration / Sovereign AI offerings, and the +**embedded CRM** captures and qualifies the demand. It runs standalone today; +the lead pipeline is the natural place to later connect Stripe billing +(`infra/ai-gateway/billing/`) when a lead converts to a paying gateway tenant. diff --git a/apps/platform/billing.py b/apps/platform/billing.py new file mode 100644 index 0000000..d6f794b --- /dev/null +++ b/apps/platform/billing.py @@ -0,0 +1,83 @@ +"""Stripe Checkout for the Allerion platform — stdlib only. + +Creates Stripe Checkout Sessions for a pricing tier via Stripe's REST API +(no `stripe` package needed). Configure with environment variables: + + STRIPE_API_KEY your Stripe secret key (sk_test_... / sk_live_...) + PLATFORM_BASE_URL public URL for success/cancel redirects + STRIPE_PRICE_TEAM (optional) an existing Stripe Price id for the Team tier + +Without STRIPE_API_KEY the platform still runs; checkout links report that +billing isn't configured yet instead of erroring. +""" +from __future__ import annotations + +import json +import os +import urllib.parse +import urllib.request + +STRIPE_API_KEY = os.environ.get("STRIPE_API_KEY", "").strip() +BASE_URL = os.environ.get("PLATFORM_BASE_URL", "http://127.0.0.1:8099").rstrip("/") + +# Sellable tiers. If a tier has a Stripe price id (env), it's used directly; +# otherwise a price is created inline from `amount` (cents) / `interval`. +TIERS = { + "team": { + "label": "Allerion Team", + "price_id": os.environ.get("STRIPE_PRICE_TEAM", "").strip(), + "amount": 200000, # $2,000.00 / month + "interval": "month", + }, +} + + +def live() -> bool: + return bool(STRIPE_API_KEY) + + +def _line_items(cfg: dict) -> list[tuple[str, str]]: + if cfg["price_id"]: + return [("line_items[0][price]", cfg["price_id"]), ("line_items[0][quantity]", "1")] + return [ + ("line_items[0][price_data][currency]", "usd"), + ("line_items[0][price_data][product_data][name]", cfg["label"]), + ("line_items[0][price_data][unit_amount]", str(cfg["amount"])), + ("line_items[0][price_data][recurring][interval]", cfg["interval"]), + ("line_items[0][quantity]", "1"), + ] + + +def create_checkout(tier: str, customer_email: str | None = None) -> dict: + """Create a Stripe Checkout Session; returns the Stripe object (has `url`).""" + cfg = TIERS.get(tier) + if not cfg: + raise ValueError(f"unknown tier: {tier}") + if not live(): + raise RuntimeError("STRIPE_API_KEY is not set — billing isn't configured") + + params = [ + ("mode", "subscription"), + ("success_url", f"{BASE_URL}/crm?checkout=success"), + ("cancel_url", f"{BASE_URL}/#pricing"), + ("allow_promotion_codes", "true"), + ] + if customer_email: + params.append(("customer_email", customer_email)) + params += _line_items(cfg) + + req = urllib.request.Request( + "https://api.stripe.com/v1/checkout/sessions", + data=urllib.parse.urlencode(params).encode(), + headers={ + "Authorization": f"Bearer {STRIPE_API_KEY}", + "Content-Type": "application/x-www-form-urlencoded", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=20) as r: + return json.loads(r.read()) + except urllib.error.HTTPError as e: + detail = e.read().decode(errors="replace") + raise RuntimeError(f"Stripe error {e.code}: {detail}") from e diff --git a/apps/platform/server.py b/apps/platform/server.py new file mode 100644 index 0000000..dbc2b48 --- /dev/null +++ b/apps/platform/server.py @@ -0,0 +1,466 @@ +#!/usr/bin/env python3 +"""Allerion platform — marketing site + embedded CRM in one app. + +Zero dependencies (Python standard library only): an HTTP server that serves +the Allerion marketing site, captures access requests as CRM leads in SQLite, +and exposes a lightweight CRM dashboard + JSON API over the same data. + + python3 server.py # http://127.0.0.1:8099 + python3 server.py --port 9000 + +Routes + GET / marketing landing page (lead-capture form) + POST /api/leads create a lead (form post or JSON) + GET /crm CRM dashboard (pipeline + lead list) + GET /api/leads list leads as JSON + POST /api/leads//status advance a lead's pipeline status + GET /healthz health check +""" +from __future__ import annotations + +import argparse +import html +import json +import os +import sqlite3 +import urllib.parse +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import billing + +DB_PATH = os.environ.get("ALLERION_CRM_DB", os.path.join(os.path.dirname(__file__), "crm.db")) +PIPELINE = ["new", "contacted", "qualified", "won", "lost"] +# Self-serve skills store (apps/store) — buy Claude Skills + the Codex plugin. +STORE_URL = os.environ.get("STORE_URL", "http://127.0.0.1:8088") + + +# --------------------------------------------------------------------------- db +def db() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db() -> None: + with db() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS leads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL, + company TEXT, + interest TEXT, + message TEXT, + status TEXT NOT NULL DEFAULT 'new', + created_at TEXT NOT NULL + ) + """ + ) + + +def create_lead(data: dict) -> dict: + name = (data.get("name") or "").strip() + email = (data.get("email") or "").strip() + if not name or not email: + raise ValueError("name and email are required") + row = { + "name": name, + "email": email, + "company": (data.get("company") or "").strip(), + "interest": (data.get("interest") or "").strip(), + "message": (data.get("message") or "").strip(), + "status": "new", + "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + with db() as conn: + cur = conn.execute( + "INSERT INTO leads (name,email,company,interest,message,status,created_at) " + "VALUES (:name,:email,:company,:interest,:message,:status,:created_at)", + row, + ) + row["id"] = cur.lastrowid + return row + + +def list_leads() -> list[dict]: + with db() as conn: + return [dict(r) for r in conn.execute("SELECT * FROM leads ORDER BY id DESC")] + + +def set_status(lead_id: int, status: str) -> bool: + if status not in PIPELINE: + raise ValueError(f"status must be one of {PIPELINE}") + with db() as conn: + cur = conn.execute("UPDATE leads SET status=? WHERE id=?", (status, lead_id)) + return cur.rowcount > 0 + + +def pipeline_counts() -> dict: + counts = {s: 0 for s in PIPELINE} + with db() as conn: + for r in conn.execute("SELECT status, COUNT(*) c FROM leads GROUP BY status"): + counts[r["status"]] = r["c"] + return counts + + +# ------------------------------------------------------------------------- views +CSS = """ +:root{ + --bg:#07090a;--bg2:#0b0f0c;--panel:#0f1411;--line:#1f2a22;--line2:#2b3a2e; + --ink:#e9f1ea;--mut:#7e8d82;--nv:#76b900;--nv2:#b6ff3a;--glow:rgba(118,185,0,.30); + --mono:ui-monospace,"SFMono-Regular","JetBrains Mono",Menlo,Consolas,monospace +} +*{box-sizing:border-box}html{scroll-behavior:smooth} +body{margin:0;background:var(--bg);color:var(--ink); + font:16px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; + -webkit-font-smoothing:antialiased} +body::before{content:"";position:fixed;inset:0;z-index:-2; + background:radial-gradient(900px 520px at 50% -12%,rgba(118,185,0,.12),transparent 70%), + linear-gradient(var(--bg2),var(--bg))} +body::after{content:"";position:fixed;inset:0;z-index:-1;opacity:.55; + background-image:linear-gradient(rgba(118,185,0,.05) 1px,transparent 1px), + linear-gradient(90deg,rgba(118,185,0,.05) 1px,transparent 1px); + background-size:46px 46px;-webkit-mask-image:radial-gradient(ellipse at 50% 0,#000,transparent 82%); + mask-image:radial-gradient(ellipse at 50% 0,#000,transparent 82%)} +a{color:var(--nv);text-decoration:none} +.wrap{max-width:1040px;margin:0 auto;padding:0 24px} +.mono{font-family:var(--mono);text-transform:uppercase;letter-spacing:.18em;font-size:12px;color:var(--mut)} +.nv{color:var(--nv)} +header{position:sticky;top:0;z-index:10;backdrop-filter:blur(10px); + background:rgba(7,9,10,.72);border-bottom:1px solid var(--line)} +header .wrap{display:flex;align-items:center;justify-content:space-between;height:62px} +.brand{font-family:var(--mono);font-weight:700;letter-spacing:.22em;font-size:14px} +.brand .mk{color:var(--nv)} +nav a{margin-left:22px;font-family:var(--mono);font-size:12px;letter-spacing:.14em; + text-transform:uppercase;color:var(--mut)} +nav a:hover{color:var(--ink)} +.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--nv); + box-shadow:0 0 0 0 var(--glow);animation:pulse 2s infinite} +@keyframes pulse{0%{box-shadow:0 0 0 0 var(--glow)}70%{box-shadow:0 0 0 10px transparent}100%{box-shadow:0 0 0 0 transparent}} +.hero{position:relative;padding:98px 0 56px;text-align:center} +.hero .ey{display:inline-flex;align-items:center;gap:10px;margin-bottom:22px; + padding:6px 14px;border:1px solid var(--line2);border-radius:999px;background:var(--panel)} +.hero h1{font-size:56px;line-height:1.03;margin:0 0 18px;letter-spacing:-.025em;font-weight:800} +.hero h1 .g{background:linear-gradient(90deg,var(--nv),var(--nv2));-webkit-background-clip:text; + background-clip:text;color:transparent} +.hero p{font-size:19px;color:var(--mut);max-width:660px;margin:0 auto 30px} +.cta{display:inline-flex;gap:12px;flex-wrap:wrap;justify-content:center} +.btn{display:inline-block;font-family:var(--mono);font-size:13px;letter-spacing:.1em;text-transform:uppercase; + background:var(--nv);color:#06140a;padding:13px 24px;border-radius:8px;font-weight:700;border:1px solid var(--nv); + transition:transform .15s,box-shadow .15s,background .15s;box-shadow:0 0 24px var(--glow);cursor:pointer} +.btn:hover{transform:translateY(-2px);box-shadow:0 0 38px var(--glow);background:var(--nv2);border-color:var(--nv2)} +.btn.ghost{background:transparent;color:var(--ink);border-color:var(--line2);box-shadow:none} +.btn.ghost:hover{border-color:var(--nv);color:var(--nv)} +section .lbl{display:flex;align-items:center;gap:14px;margin:62px 0 22px; + font-family:var(--mono);text-transform:uppercase;letter-spacing:.18em;font-size:12px;color:var(--mut)} +section .lbl::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--line2),transparent)} +.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px} +.card{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:24px; + transition:border-color .2s,transform .2s,box-shadow .2s;overflow:hidden} +.card::before{content:"";position:absolute;left:0;top:0;width:32px;height:32px; + border-top:2px solid var(--nv);border-left:2px solid var(--nv);opacity:.45;transition:opacity .2s} +.card::after{content:"";position:absolute;right:0;bottom:0;width:32px;height:32px; + border-bottom:2px solid var(--nv);border-right:2px solid var(--nv);opacity:.45;transition:opacity .2s} +.card:hover{border-color:var(--line2);transform:translateY(-3px);box-shadow:0 14px 44px rgba(0,0,0,.55)} +.card:hover::before,.card:hover::after{opacity:1} +.card .ix{font-family:var(--mono);font-size:12px;color:var(--nv);letter-spacing:.16em} +.card h3{margin:8px 0 6px;font-size:18px} +.card p{margin:0;color:var(--mut);font-size:14px} +.telem{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:22px 0} +.telem .t{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px} +.telem .k{font-family:var(--mono);font-size:11px;color:var(--mut);letter-spacing:.14em;text-transform:uppercase} +.telem .v{font-size:26px;font-weight:800;margin-top:4px} +.telem .v small{color:var(--nv);font-size:13px;font-weight:600} +.bars{display:flex;gap:3px;align-items:flex-end;height:24px;margin-top:12px} +.bars i{flex:1;background:var(--nv);opacity:.7;border-radius:1px;height:30%;animation:eq 1.2s ease-in-out infinite} +.bars i:nth-child(2){animation-delay:.12s}.bars i:nth-child(3){animation-delay:.24s} +.bars i:nth-child(4){animation-delay:.36s}.bars i:nth-child(5){animation-delay:.48s} +.bars i:nth-child(6){animation-delay:.6s}.bars i:nth-child(7){animation-delay:.72s} +@keyframes eq{0%,100%{height:25%}50%{height:100%}} +.price{display:grid;grid-template-columns:repeat(3,1fr);gap:14px} +.price .card{text-align:center} +.price .card.feat{border-color:var(--nv);box-shadow:0 0 30px var(--glow)} +.price .amt{font-size:32px;font-weight:800;margin:10px 0} +.price .amt small{font-size:13px;color:var(--mut);font-weight:500;font-family:var(--mono)} +form{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:26px;margin:18px 0 80px} +label{display:block;font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase; + color:var(--mut);margin:14px 0 6px} +input,select,textarea{width:100%;background:#070a08;border:1px solid var(--line2);color:var(--ink); + border-radius:8px;padding:11px 13px;font:inherit;transition:border-color .15s,box-shadow .15s} +input:focus,select:focus,textarea:focus{outline:none;border-color:var(--nv);box-shadow:0 0 0 3px var(--glow)} +textarea{min-height:88px;resize:vertical} +table{width:100%;border-collapse:collapse;margin:16px 0} +th,td{text-align:left;padding:12px 10px;border-bottom:1px solid var(--line);font-size:14px} +th{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--mut)} +td .em{color:var(--mut);font-size:12px;font-family:var(--mono)} +.pill{display:inline-block;padding:3px 11px;border-radius:999px;font-size:11px;font-family:var(--mono); + text-transform:uppercase;letter-spacing:.08em;border:1px solid var(--line2)} +.s-new{color:#7ab8ff;border-color:#274a6b}.s-contacted{color:var(--nv2);border-color:#3a4a16} +.s-qualified{color:var(--nv);border-color:#2d4a12}.s-won{color:#39e07d;border-color:#1d5a35}.s-lost{color:#8b97a3} +.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin:18px 0} +.kpi{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px;text-align:center} +.kpi b{display:block;font-size:30px;font-weight:800;color:var(--nv)} +.kpi span{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--mut)} +footer{border-top:1px solid var(--line);color:var(--mut);padding:30px 0;text-align:center} +.note{color:var(--mut);font-size:12px;font-family:var(--mono);letter-spacing:.04em} +code{font-family:var(--mono);color:var(--nv);font-size:13px} +""" + +CAPABILITIES = [ + ("Digital Twin", "3D BIM infrastructure intelligence — real-time spatial data at industrial scale."), + ("Construction ERP", "Autonomous cost estimation, BOQ generation, CAD/BIM takeoff."), + ("Agent Orchestration", "Multi-agent swarms — agents hiring agents via the A2A protocol."), + ("Sovereign AI", "Self-hosted LLM deployment on private infrastructure — your models, your silicon."), +] + +# name, amount, period, description, cta-tier (None → request-access anchor) +PRICING = [ + ("Gateway", "$0", "/mo to start", "Branded OpenAI-compatible API at api.allerion.io. Pay only for usage.", None), + ("Team", "$2k", "/mo", "Virtual keys, budgets, metered Stripe billing, priority routing.", "team"), + ("Sovereign", "Custom", "", "Self-hosted GPU deployment + on-prem agent orchestration.", None), +] + + +def page(title: str, body: str) -> bytes: + doc = f""" + +{html.escape(title)} +
+
ALLERION.IO
+ +
+{body} +
Allerion Systems — autonomous intelligence for the built world
+
We don't sell software. We deploy intelligence.
+""" + return doc.encode() + + +def landing() -> bytes: + caps = "".join( + f'
{i:02d}
' + f'

{html.escape(t)}

{html.escape(d)}

' + for i, (t, d) in enumerate(CAPABILITIES, 1) + ) + def price_cta(tier): + if tier: + return f'

Subscribe →

' + return '

Request access

' + + price = "".join( + f'
{html.escape(n)}
' + f'
{html.escape(a)} {html.escape(p)}
' + f'

{html.escape(d)}

{price_cta(tier)}
' + for i, (n, a, p, d, tier) in enumerate(PRICING) + ) + bars = "".join("" for _ in range(7)) + body = f""" +
+
Autonomous systems · online
+

Autonomous intelligence
for the built world.

+

We design, deploy, and operate multi-agent AI systems for infrastructure, + construction, and industrial operations. Our agents estimate. Our agents build. + Our agents never sleep.

+ +
+
+
+
Agents online
80
+
Models routed
80+ NIM
+
Fleet uptime
99.9%
+
Throughput
{bars}
+
+
// Capabilities
+
{caps}
+
// Pricing
+
{price}
+
// Request access
+
+ + + + + + +

+

// submitting creates a lead in the embedded CRM

+
+
""" + return page("Allerion — Autonomous AI for the built world", body) + + +def crm_page() -> bytes: + counts = pipeline_counts() + kpis = "".join( + f'
{counts[s]}{s}
' for s in PIPELINE + ) + rows = [] + for L in list_leads(): + opts = "".join( + f'' + for s in PIPELINE + ) + rows.append( + f"#{L['id']}{html.escape(L['name'])}
" + f"{html.escape(L['email'])}" + f"{html.escape(L['company'] or '—')}" + f"{html.escape(L['interest'] or '—')}" + f"{L['status']}" + f"
" + f"" + f"
" + ) + table = ( + "" + "" + ("".join(rows) or + "") + + "
IDLeadCompanyInterestStatusUpdate
No leads yet — submit the form on the landing page.
" + ) + body = f"""
+
+ CRM console · live
+
// Pipeline telemetry
+
{kpis}
+ {table} +

// embedded CRM over the same SQLite store · JSON API at /api/leads

+
""" + return page("Allerion Console — CRM", body) + + +# ----------------------------------------------------------------------- handler +class Handler(BaseHTTPRequestHandler): + server_version = "AllerionPlatform/0.1" + + def log_message(self, *a): # quieter logs + pass + + def _send(self, code: int, body: bytes, ctype="text/html; charset=utf-8"): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _json(self, code: int, obj): + self._send(code, json.dumps(obj, indent=2).encode(), "application/json") + + def _body_params(self) -> dict: + n = int(self.headers.get("Content-Length", 0) or 0) + raw = self.rfile.read(n).decode() if n else "" + ctype = self.headers.get("Content-Type", "") + if "application/json" in ctype: + try: + return json.loads(raw or "{}") + except json.JSONDecodeError: + return {} + return {k: v[0] for k, v in urllib.parse.parse_qs(raw).items()} + + def do_GET(self): + path = urllib.parse.urlparse(self.path).path + if path == "/": + self._send(200, landing()) + elif path == "/crm": + self._send(200, crm_page()) + elif path == "/api/leads": + self._json(200, {"leads": list_leads()}) + elif path.startswith("/buy/"): + self._checkout(path.split("/")[2]) + elif path == "/healthz": + self._json(200, {"ok": True, "billing": billing.live()}) + else: + self._send(404, page("Not found", '

404

')) + + def _checkout(self, tier: str): + """Create a Stripe Checkout Session and redirect the buyer to it.""" + if tier not in billing.TIERS: + return self._send(404, page("Unknown plan", + '

Unknown plan

' + '

No such tier.

Back to pricing
')) + if not billing.live(): + return self._send(200, page("Checkout — configuring", f""" +
+
Billing · pending key
+

Almost live.

+

Stripe Checkout for the {html.escape(tier.title())} plan is wired and ready — + it just needs the Stripe secret key set as STRIPE_API_KEY. + Once that's in the environment, this button opens Stripe Checkout directly.

+ Request access + Open console +
""")) + try: + session = billing.create_checkout(tier) + except Exception as e: # noqa: BLE001 — surface a friendly page + return self._send(502, page("Checkout error", f""" +

Checkout unavailable

+

{html.escape(str(e))[:300]}

+ Back to pricing
""")) + self.send_response(303) + self.send_header("Location", session["url"]) + self.end_headers() + + def do_POST(self): + path = urllib.parse.urlparse(self.path).path + params = self._body_params() + wants_json = "application/json" in self.headers.get("Content-Type", "") + + if path == "/api/leads": + try: + lead = create_lead(params) + except ValueError as e: + return self._json(400, {"error": str(e)}) + if wants_json: + return self._json(201, lead) + # browser form post → redirect to CRM + self.send_response(303) + self.send_header("Location", "/crm") + self.end_headers() + return + + if path.startswith("/api/leads/") and path.endswith("/status"): + try: + lead_id = int(path.split("/")[3]) + except (IndexError, ValueError): + return self._json(404, {"error": "bad lead id"}) + try: + ok = set_status(lead_id, (params.get("status") or "").strip()) + except ValueError as e: + return self._json(400, {"error": str(e)}) + if not ok: + return self._json(404, {"error": "lead not found"}) + if wants_json: + return self._json(200, {"id": lead_id, "status": params["status"]}) + self.send_response(303) + self.send_header("Location", "/crm") + self.end_headers() + return + + self._json(404, {"error": "not found"}) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--port", type=int, default=8099) + ap.add_argument("--host", default="127.0.0.1") + args = ap.parse_args() + init_db() + srv = ThreadingHTTPServer((args.host, args.port), Handler) + print(f"Allerion platform on http://{args.host}:{args.port} (CRM at /crm)") + try: + srv.serve_forever() + except KeyboardInterrupt: + srv.shutdown() + + +if __name__ == "__main__": + main() diff --git a/apps/store/.dockerignore b/apps/store/.dockerignore new file mode 100644 index 0000000..748f0e7 --- /dev/null +++ b/apps/store/.dockerignore @@ -0,0 +1,8 @@ +store.db +__pycache__/ +*.pyc +.pytest_cache/ +tests/ +.env +Dockerfile +.dockerignore diff --git a/apps/store/.gitignore b/apps/store/.gitignore new file mode 100644 index 0000000..4c5b535 --- /dev/null +++ b/apps/store/.gitignore @@ -0,0 +1,5 @@ +store.db +__pycache__/ +*.pyc +.pytest_cache/ +.env diff --git a/apps/store/Dockerfile b/apps/store/Dockerfile new file mode 100644 index 0000000..1c2e6a8 --- /dev/null +++ b/apps/store/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +WORKDIR /app +# Stdlib-only server. Products under products/ are zipped on the fly at delivery, +# so the whole app directory is copied in (.dockerignore trims the cruft). +COPY . . +ENV STORE_DB=/data/store.db +EXPOSE 8088 +CMD ["python3", "server.py", "--host", "0.0.0.0", "--port", "8088"] diff --git a/apps/store/README.md b/apps/store/README.md new file mode 100644 index 0000000..5e79e09 --- /dev/null +++ b/apps/store/README.md @@ -0,0 +1,107 @@ +# Allerion Skills Store + +A zero-dependency storefront (Python standard library only) that **sells digital +developer products** — Claude Agent Skills and a Codex MCP plugin — through +Stripe one-time checkout, with instant, license-gated download delivery. + +```bash +cd apps/store +python3 server.py # http://127.0.0.1:8088 +``` + +No `pip install` — it uses only `http.server`, `sqlite3`, `zipfile`, and `urllib`. + +## What it sells + +| Product | Kind | Price | +|---------|------|-------| +| **PR Review Pro** | Claude Skill | $49 | +| **Changelog Craft** | Claude Skill | $29 | +| **Test Forge** | Claude Skill | $39 | +| **Allerion DevKit for Codex** | Codex plugin (MCP) | $79 | +| **DevKit Complete Bundle** | All four | $149 | + +The products are real and live under `products/`: + +- `products/claude-skills/*` — each a Claude Agent Skill (a `SKILL.md` plus + helper scripts). Drop the folder into `~/.claude/skills/`. +- `products/codex-devkit/` — an MCP server (`server.py`) that adds `review_diff`, + `draft_changelog`, and `scaffold_tests` to OpenAI Codex. Verify it with + `python3 products/codex-devkit/server.py --selftest`. + +## How a sale works + +1. Buyer clicks **Buy** → `GET /buy/` creates a Stripe Checkout Session + (`mode=payment`) and redirects to Stripe. +2. Stripe sends the buyer back to `GET /success?session_id=…`. The server + **retrieves the session from Stripe and confirms `payment_status == "paid"`** + before delivering anything — the redirect alone is never trusted. +3. The order is recorded in SQLite and a **signed license token** is minted + (HMAC-SHA256 over `{slug, email}`), tied to the buyer's email. +4. `GET /download?token=…` verifies the signature and streams a zip built on the + fly from the product's source dirs, with a **personalized `LICENSE.txt`** and + a `README-FIRST.txt` injected. Forged or altered tokens get a `403`. + +## Going live (Stripe) + +The store runs without Stripe (storefront preview). To take real payments: + +```bash +export STRIPE_API_KEY=sk_live_... # or sk_test_... to rehearse +export STORE_BASE_URL=https://skills.allerion.io +export STORE_SIGNING_SECRET=$(python3 -c "import secrets;print(secrets.token_hex(32))") +python3 server.py --host 0.0.0.0 --port 8088 +``` + +- `STRIPE_API_KEY` — turns checkout on. With `sk_test_…` you can run the full + flow using Stripe test cards (e.g. `4242 4242 4242 4242`). +- `STORE_BASE_URL` — public URL for Stripe success/cancel redirects. +- `STORE_SIGNING_SECRET` — **required for production.** Without it, license + tokens are signed with a known dev key and the server warns on startup. + +Optional — use stable Stripe Price ids instead of inline prices: + +```bash +STRIPE_API_KEY=sk_test_... python3 setup_stripe.py # prints export lines +``` + +## Routes + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/` | Storefront (product grid) | +| GET | `/api/products` | Catalog as JSON | +| GET | `/buy/` | Create Stripe Checkout + redirect | +| GET | `/success?session_id=…` | Verify payment, mint license, show download | +| GET | `/download?token=…` | Verify license, stream product zip | +| GET | `/healthz` | Health + Stripe/signing status + order count | + +## Local testing without Stripe + +Set `STORE_DEV_FULFILLMENT=1` to enable a **dev grant** link on the checkout page +that mints a license without payment — for testing fulfillment on your machine +only. Dev grants are recorded with `source='dev'` and never counted as revenue. + +```bash +STORE_DEV_FULFILLMENT=1 STORE_SIGNING_SECRET=test python3 server.py --port 8090 +``` + +## Tests + +```bash +python3 -m pytest tests/ -q # catalog, license signing, zip delivery +python3 products/codex-devkit/server.py --selftest +``` + +## Data + +SQLite at `apps/store/store.db` (git-ignored), overridable via `STORE_DB`. The +`orders` table records each fulfilled purchase (slug, email, amount, Stripe +session id) and is created automatically. + +## How it fits the platform + +This is the **revenue front** of the Allerion stack. The marketing site + +embedded CRM (`apps/platform/`) sells the platform tiers and captures leads; this +store sells productized, self-serve developer tools that anyone can buy and +download in one click. Both checkout flows run through the same Stripe account. diff --git a/apps/store/catalog.py b/apps/store/catalog.py new file mode 100644 index 0000000..dc9d4ab --- /dev/null +++ b/apps/store/catalog.py @@ -0,0 +1,125 @@ +"""Product catalog for the Allerion Skills Store. + +Each product is a digital download (Claude Skill or Codex plugin) sold as a +one-time Stripe purchase. `source` paths are relative to this file and are zipped +on the fly at delivery time (see fulfillment.py). + +Prices are in USD cents. To use pre-created Stripe Price ids instead of inline +price_data, set the matching env var (e.g. STRIPE_PRICE_PR_REVIEW_PRO). +""" +from __future__ import annotations + +import os + +# slug -> product definition +PRODUCTS: dict[str, dict] = { + "pr-review-pro": { + "name": "PR Review Pro", + "kind": "Claude Skill", + "price": 4900, # $49.00 + "tagline": "Senior-engineer pull-request reviews, on demand.", + "blurb": "A Claude Agent Skill that reviews diffs the way a strong senior " + "engineer does — correctness and security first, severity-ranked " + "findings with fixes, and a plain merge / don't-merge verdict. " + "Ships with a diff risk-profiler script.", + "sources": ["products/claude-skills/pr-review-pro"], + "price_env": "STRIPE_PRICE_PR_REVIEW_PRO", + }, + "changelog-craft": { + "name": "Changelog Craft", + "kind": "Claude Skill", + "price": 2900, # $29.00 + "tagline": "Git history → release notes a human can read.", + "blurb": "Turns raw commits into grouped, rewritten release notes, flags " + "breaking changes, and recommends the semver bump. Includes a " + "Conventional-Commit grouping script.", + "sources": ["products/claude-skills/changelog-craft"], + "price_env": "STRIPE_PRICE_CHANGELOG_CRAFT", + }, + "test-forge": { + "name": "Test Forge", + "kind": "Claude Skill", + "price": 3900, # $39.00 + "tagline": "Tests that pin behavior, not restate the code.", + "blurb": "Maps a file's testable surface, enumerates real edge cases, and " + "writes tests that would fail on regression. Includes a " + "surface-mapping script (precise for Python, heuristic elsewhere).", + "sources": ["products/claude-skills/test-forge"], + "price_env": "STRIPE_PRICE_TEST_FORGE", + }, + "allerion-assistant": { + "name": "Allerion Assistant", + "kind": "Self-hosted AI App", + "price": 12900, # $129.00 + "tagline": "Your own AI chat — self-hosted, powered by Claude.", + "blurb": "A complete, brandable AI assistant you run yourself: a streaming " + "chat web UI plus a backend on the official Anthropic SDK " + "(Claude Opus 4.8, adaptive thinking, effort control). Point it at " + "the Anthropic API or your own gateway. One file to run, yours to " + "rebrand, persist, and extend with tools.", + "sources": ["products/allerion-assistant"], + "price_env": "STRIPE_PRICE_ALLERION_ASSISTANT", + }, + "codex-devkit": { + "name": "Allerion DevKit for Codex", + "kind": "Codex Plugin (MCP)", + "price": 7900, # $79.00 + "tagline": "review · changelog · test — inside OpenAI Codex.", + "blurb": "A Model Context Protocol server that adds review_diff, " + "draft_changelog, and scaffold_tests to OpenAI Codex. Pure Python, " + "local-only, no API key. Drop one block into ~/.codex/config.toml.", + "sources": ["products/codex-devkit"], + "price_env": "STRIPE_PRICE_CODEX_DEVKIT", + }, + "devkit-bundle": { + "name": "Allerion DevKit — Complete Bundle", + "kind": "Bundle", + "price": 14900, # $149.00 (vs $196 a la carte) + "tagline": "All three skills + the Codex plugin. Save 24%.", + "blurb": "Everything: PR Review Pro, Changelog Craft, Test Forge, and the " + "Codex DevKit MCP plugin — the same toolkit across Claude and Codex. " + "One download, one license.", + "sources": [ + "products/claude-skills/pr-review-pro", + "products/claude-skills/changelog-craft", + "products/claude-skills/test-forge", + "products/codex-devkit", + ], + "price_env": "STRIPE_PRICE_DEVKIT_BUNDLE", + }, +} + +LICENSE_TEMPLATE = """\ +ALLERION SYSTEMS — COMMERCIAL LICENSE +===================================== + +Product : {product} +Licensee: {email} +License : {license_key} +Issued : {issued} + +Grant +----- +Allerion Systems grants the licensee a non-exclusive, non-transferable license +to use this product for internal software-development purposes, for one (1) +developer seat. + +You MAY: use it in personal and commercial projects, modify it for your own use, +and run it on any machine you personally develop on. + +You MAY NOT: resell, sublicense, or redistribute the product or substantial +portions of it as a competing product; remove this license notice. + +No warranty. Provided "as is". Questions: https://allerion.io + +This license key is tied to the licensee email above and was issued at purchase. +""" + + +def usd(cents: int) -> str: + return f"${cents / 100:,.2f}".rstrip("0").rstrip(".") if cents % 100 else f"${cents // 100}" + + +def price_id(slug: str) -> str: + p = PRODUCTS.get(slug, {}) + return os.environ.get(p.get("price_env", ""), "").strip() diff --git a/apps/store/fulfillment.py b/apps/store/fulfillment.py new file mode 100644 index 0000000..6370d0a --- /dev/null +++ b/apps/store/fulfillment.py @@ -0,0 +1,123 @@ +"""Digital fulfillment: signed license tokens + on-the-fly product zips. + +A license token is a signed, self-verifying claim that someone bought a product — +so the download endpoint can authorize delivery without trusting the URL. Format: + + base64url(payload_json) . base64url(hmac_sha256(payload_json)) + +The HMAC key is STORE_SIGNING_SECRET (env). On purchase we mint a token tied to +{slug, email}; the download endpoint verifies the signature, then streams a zip +built from the product's source dirs with a personalized LICENSE.txt injected. +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import io +import json +import os +import zipfile +from datetime import datetime, timezone + +import catalog + +HERE = os.path.dirname(os.path.abspath(__file__)) +_DEV_SECRET = "allerion-dev-signing-secret-change-me" +SIGNING_SECRET = os.environ.get("STORE_SIGNING_SECRET", _DEV_SECRET) + + +def signing_is_production() -> bool: + return SIGNING_SECRET != _DEV_SECRET + + +def _b64(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def _unb64(s: str) -> bytes: + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) + + +def mint_license(slug: str, email: str) -> str: + """Return a signed download token for (slug, email).""" + payload = { + "slug": slug, + "email": email, + "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode() + sig = hmac.new(SIGNING_SECRET.encode(), raw, hashlib.sha256).digest() + return f"{_b64(raw)}.{_b64(sig)}" + + +def verify_license(token: str) -> dict | None: + """Return the payload dict if the token is authentic and known, else None.""" + try: + body_b64, sig_b64 = token.split(".", 1) + raw = _unb64(body_b64) + expected = hmac.new(SIGNING_SECRET.encode(), raw, hashlib.sha256).digest() + if not hmac.compare_digest(expected, _unb64(sig_b64)): + return None + payload = json.loads(raw) + except (ValueError, json.JSONDecodeError): + return None + if payload.get("slug") not in catalog.PRODUCTS: + return None + return payload + + +def license_key(token: str) -> str: + """A short, human-friendly key derived from the token (for the LICENSE file).""" + digest = hashlib.sha256(token.encode()).hexdigest().upper() + return "-".join(digest[i:i + 4] for i in range(0, 16, 4)) + + +def build_zip(slug: str, email: str, token: str) -> bytes: + """Build the deliverable zip for a product in memory.""" + product = catalog.PRODUCTS[slug] + buf = io.BytesIO() + issued = datetime.now(timezone.utc).strftime("%Y-%m-%d") + license_text = catalog.LICENSE_TEMPLATE.format( + product=product["name"], email=email or "licensed user", + license_key=license_key(token), issued=issued) + + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: + for src in product["sources"]: + abs_src = os.path.join(HERE, src) + top = os.path.basename(abs_src.rstrip("/")) + for root, _dirs, names in os.walk(abs_src): + # skip caches / vcs cruft (match path components, not substrings — + # the repo itself lives under a ".github" dir) + parts = set(root.split(os.sep)) + if "__pycache__" in parts or ".git" in parts: + continue + for name in names: + if name.endswith((".pyc", ".pyo")): + continue + full = os.path.join(root, name) + rel = os.path.relpath(full, abs_src) + arc = os.path.join(top, rel) + zf.write(full, arc) + # personalized license inside each product folder + zf.writestr(os.path.join(top, "LICENSE.txt"), license_text) + zf.writestr("README-FIRST.txt", _readme_first(product, email, issued)) + return buf.getvalue() + + +def _readme_first(product: dict, email: str, issued: str) -> str: + return ( + f"Thank you for purchasing {product['name']} from Allerion Systems.\n\n" + f"Licensed to: {email or 'you'} Issued: {issued}\n\n" + "WHAT'S INSIDE\n" + f" This bundle contains: {', '.join(os.path.basename(s) for s in product['sources'])}\n\n" + "CLAUDE SKILLS (folders containing a SKILL.md)\n" + " Copy the skill folder into your Claude skills directory:\n" + " Claude Code: ~/.claude/skills//\n" + " Then it loads automatically when relevant, or invoke it by name.\n\n" + "CODEX PLUGIN (codex-devkit/)\n" + " See codex-devkit/README.md — add one block to ~/.codex/config.toml.\n" + " Verify with: python3 codex-devkit/server.py --selftest\n\n" + "Each folder has its own LICENSE.txt tied to your purchase.\n" + "Support & updates: https://allerion.io\n" + ) diff --git a/apps/store/payments.py b/apps/store/payments.py new file mode 100644 index 0000000..a46050c --- /dev/null +++ b/apps/store/payments.py @@ -0,0 +1,82 @@ +"""Stripe one-time checkout for digital products — stdlib only. + +Creates Checkout Sessions in `payment` mode (not subscription) and verifies a +session was actually paid before we hand over a download. No `stripe` package. + +Env: + STRIPE_API_KEY secret key (sk_test_… / sk_live_…) + STORE_BASE_URL public base URL for success/cancel redirects +""" +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.parse +import urllib.request + +import catalog + +STRIPE_API_KEY = os.environ.get("STRIPE_API_KEY", "").strip() +BASE_URL = os.environ.get("STORE_BASE_URL", "http://127.0.0.1:8088").rstrip("/") +_API = "https://api.stripe.com/v1" + + +def live() -> bool: + return bool(STRIPE_API_KEY) + + +def _request(method: str, path: str, params: list[tuple[str, str]] | None = None) -> dict: + url = f"{_API}{path}" + data = urllib.parse.urlencode(params).encode() if params else None + req = urllib.request.Request( + url, data=data, + headers={"Authorization": f"Bearer {STRIPE_API_KEY}", + "Content-Type": "application/x-www-form-urlencoded"}, + method=method) + try: + with urllib.request.urlopen(req, timeout=20) as r: + return json.loads(r.read()) + except urllib.error.HTTPError as e: + detail = e.read().decode(errors="replace") + raise RuntimeError(f"Stripe {e.code}: {detail[:300]}") from e + + +def _line_items(slug: str, cfg: dict) -> list[tuple[str, str]]: + pid = catalog.price_id(slug) + if pid: + return [("line_items[0][price]", pid), ("line_items[0][quantity]", "1")] + return [ + ("line_items[0][price_data][currency]", "usd"), + ("line_items[0][price_data][product_data][name]", cfg["name"]), + ("line_items[0][price_data][product_data][description]", cfg["tagline"]), + ("line_items[0][price_data][unit_amount]", str(cfg["price"])), + ("line_items[0][quantity]", "1"), + ] + + +def create_checkout(slug: str) -> dict: + """Create a one-time Checkout Session for a product; returns Stripe object.""" + cfg = catalog.PRODUCTS.get(slug) + if not cfg: + raise ValueError(f"unknown product: {slug}") + if not live(): + raise RuntimeError("STRIPE_API_KEY not set") + params = [ + ("mode", "payment"), + ("success_url", f"{BASE_URL}/success?session_id={{CHECKOUT_SESSION_ID}}"), + ("cancel_url", f"{BASE_URL}/#catalog"), + ("allow_promotion_codes", "true"), + ("metadata[slug]", slug), + ("payment_intent_data[metadata][slug]", slug), + ] + params += _line_items(slug, cfg) + return _request("POST", "/checkout/sessions", params) + + +def retrieve_session(session_id: str) -> dict: + """Fetch a Checkout Session to confirm payment + read buyer email/slug.""" + if not live(): + raise RuntimeError("STRIPE_API_KEY not set") + safe = urllib.parse.quote(session_id, safe="") + return _request("GET", f"/checkout/sessions/{safe}") diff --git a/apps/store/products/allerion-assistant/.env.example b/apps/store/products/allerion-assistant/.env.example new file mode 100644 index 0000000..8d08575 --- /dev/null +++ b/apps/store/products/allerion-assistant/.env.example @@ -0,0 +1,14 @@ +# Copy to .env (or export these) before running assistant.py + +# Required — your Anthropic API key +ANTHROPIC_API_KEY=sk-ant-... + +# Optional — point at your own Anthropic-compatible gateway instead of the API +# ANTHROPIC_BASE_URL=https://api.allerion.io + +# Optional — tune the assistant +# ASSISTANT_NAME=Allerion Assistant +# ASSISTANT_MODEL=claude-opus-4-8 +# ASSISTANT_EFFORT=high # low | medium | high | max (xhigh on supported models) +# ASSISTANT_MAX_TOKENS=8000 +# ASSISTANT_SYSTEM=You are a helpful assistant for my company. Be concise. diff --git a/apps/store/products/allerion-assistant/README.md b/apps/store/products/allerion-assistant/README.md new file mode 100644 index 0000000..6cb9e20 --- /dev/null +++ b/apps/store/products/allerion-assistant/README.md @@ -0,0 +1,69 @@ +# Allerion Assistant + +Your own AI assistant — a complete, self-hostable chat app you run and brand +yourself. Streaming web UI + a backend built on the **official Anthropic SDK**. +Point it at the Anthropic API directly, or at your own gateway. + +## What you get + +- A polished streaming chat UI (dark, on-brand) — no build step, one HTML page + served by the app. +- A backend on `claude-opus-4-8` with **adaptive thinking** and **effort** + control, streaming tokens (and a live "thinking" trace) to the browser. +- Multi-turn memory per browser session, with the correct Opus multi-turn + replay (thinking blocks preserved across turns). +- Fully configurable by environment: model, effort, system prompt / persona, + max tokens, and a custom base URL so it can run against your own gateway. +- Graceful error + refusal handling surfaced to the UI. + +It's yours: change the persona, restyle the page, add tools, swap the in-memory +store for a database. ~1 file, no framework. + +## Run it + +```bash +pip install -r requirements.txt +export ANTHROPIC_API_KEY=sk-ant-... # get one at console.anthropic.com +python3 assistant.py # http://127.0.0.1:8077 +python3 assistant.py --host 0.0.0.0 --port 9000 +``` + +Open the URL and chat. Health check at `/healthz`. + +## Configure + +All optional (see `.env.example`): + +| Variable | Default | Purpose | +|---|---|---| +| `ANTHROPIC_API_KEY` | — | **Required.** Your Anthropic key. | +| `ANTHROPIC_BASE_URL` | api.anthropic.com | Point at your own Anthropic-compatible gateway. | +| `ASSISTANT_MODEL` | `claude-opus-4-8` | Any current Claude model. | +| `ASSISTANT_EFFORT` | `high` | `low` / `medium` / `high` / `max` (some models also accept `xhigh`). Lower = faster/cheaper. | +| `ASSISTANT_SYSTEM` | a sensible default | The assistant's persona / instructions. | +| `ASSISTANT_MAX_TOKENS` | `8000` | Max output tokens per reply. | +| `ASSISTANT_NAME` | `Allerion Assistant` | Title shown in the UI. | + +Want a snappier, cheaper assistant? Set `ASSISTANT_EFFORT=low` and +`ASSISTANT_MODEL=claude-haiku-4-5`. Want the most capable? `ASSISTANT_MODEL=claude-fable-5`. + +## Make it yours + +- **Persona**: set `ASSISTANT_SYSTEM` (e.g. "You are the support bot for Acme…"). +- **Branding**: the UI is the `PAGE` string in `assistant.py` — restyle freely. +- **Persistence**: `SESSIONS` is an in-memory dict; back it with Redis/Postgres + to survive restarts and scale across workers. +- **Tools**: the backend uses `client.messages.stream(...)` — add a `tools=[...]` + list and a tool-execution loop to give your assistant abilities. + +## What's included + +- `assistant.py` — the app (UI + backend) +- `requirements.txt` — one dependency, the official `anthropic` SDK +- `.env.example` — configuration template +- `LICENSE.txt` — commercial license (one seat per purchase) + +## License + +Commercial license, one developer seat per purchase. See `LICENSE.txt`. +Sold by Allerion Systems — https://allerion.io diff --git a/apps/store/products/allerion-assistant/assistant.py b/apps/store/products/allerion-assistant/assistant.py new file mode 100644 index 0000000..650555d --- /dev/null +++ b/apps/store/products/allerion-assistant/assistant.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Allerion Assistant — your own self-hostable AI chat, powered by Claude. + +A complete, brandable AI assistant you run yourself: a streaming chat web UI +plus a backend built on the official Anthropic SDK. Point it at the Anthropic +API directly, or at your own Anthropic-compatible gateway via ANTHROPIC_BASE_URL. + + pip install -r requirements.txt + export ANTHROPIC_API_KEY=sk-ant-... + python3 assistant.py # http://127.0.0.1:8077 + +Config (all optional, via environment): + ANTHROPIC_API_KEY your key (required) + ANTHROPIC_BASE_URL point at your own gateway instead of api.anthropic.com + ASSISTANT_MODEL default: claude-opus-4-8 + ASSISTANT_EFFORT low | medium | high | max (default: high; + some models also accept xhigh — check your model) + ASSISTANT_SYSTEM the assistant's persona / system prompt + ASSISTANT_MAX_TOKENS default: 8000 + ASSISTANT_NAME UI title (default: Allerion Assistant) +""" +from __future__ import annotations + +import argparse +import json +import os +import urllib.parse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import anthropic + +MODEL = os.environ.get("ASSISTANT_MODEL", "claude-opus-4-8") +EFFORT = os.environ.get("ASSISTANT_EFFORT", "high") +MAX_TOKENS = int(os.environ.get("ASSISTANT_MAX_TOKENS", "8000")) +NAME = os.environ.get("ASSISTANT_NAME", "Allerion Assistant") +SYSTEM = os.environ.get( + "ASSISTANT_SYSTEM", + f"You are {NAME}, a sharp, helpful AI assistant. Be clear and concise; " + "lead with the answer, then the reasoning. Use code blocks for code.", +) + +# Reads ANTHROPIC_API_KEY (and optional ANTHROPIC_BASE_URL) from the environment. +client = anthropic.Anthropic() + +# In-memory conversation store, keyed by browser-supplied session id. Swap for a +# database to persist across restarts / scale across processes. +SESSIONS: dict[str, list] = {} + + +def stream_reply(session_id: str, user_message: str): + """Yield (kind, text) tuples — kind is 'thinking', 'text', or 'error'.""" + history = SESSIONS.setdefault(session_id, []) + history.append({"role": "user", "content": user_message}) + try: + with client.messages.stream( + model=MODEL, + max_tokens=MAX_TOKENS, + system=SYSTEM, + thinking={"type": "adaptive", "display": "summarized"}, + output_config={"effort": EFFORT}, + messages=history, + ) as stream: + for event in stream: + if event.type == "content_block_delta": + if event.delta.type == "thinking_delta": + yield "thinking", event.delta.thinking + elif event.delta.type == "text_delta": + yield "text", event.delta.text + final = stream.get_final_message() + except anthropic.APIStatusError as e: # surface a clean message to the UI + history.pop() # don't keep a turn we couldn't answer + yield "error", f"API error {e.status_code}: {e.message}" + return + except anthropic.APIConnectionError: + history.pop() + yield "error", "Could not reach the API. Check your network / base URL." + return + + if final.stop_reason == "refusal": + history.pop() + yield "error", "The model declined to answer that request." + return + + # Persist the full assistant turn (content blocks, incl. thinking) so the + # next turn replays them unchanged — the correct multi-turn pattern on Opus. + history.append({"role": "assistant", "content": final.content}) + + +PAGE = """ + +__NAME__ +
__NAME____MODEL__
+
+
+ + +
+""" + + +class Handler(BaseHTTPRequestHandler): + server_version = "AllerionAssistant/1.0" + + def log_message(self, *a): + pass + + def do_GET(self): + path = urllib.parse.urlparse(self.path).path + if path == "/": + body = (PAGE.replace("__NAME__", NAME).replace("__MODEL__", MODEL)).encode() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + elif path == "/healthz": + body = json.dumps({"ok": True, "model": MODEL}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + self.send_error(404) + + def do_POST(self): + if urllib.parse.urlparse(self.path).path != "/api/chat": + return self.send_error(404) + n = int(self.headers.get("Content-Length", 0) or 0) + try: + data = json.loads(self.rfile.read(n) or "{}") + except json.JSONDecodeError: + return self.send_error(400) + session_id = str(data.get("session_id") or "default") + message = (data.get("message") or "").strip() + if not message: + return self.send_error(400) + + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + for kind, text in stream_reply(session_id, message): + chunk = f"data: {json.dumps({'kind': kind, 'text': text})}\n\n".encode() + try: + self.wfile.write(chunk) + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + break # browser navigated away mid-stream + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--port", type=int, default=8077) + ap.add_argument("--host", default="127.0.0.1") + args = ap.parse_args() + if not os.environ.get("ANTHROPIC_API_KEY") and not os.environ.get("ANTHROPIC_AUTH_TOKEN"): + print("WARNING: ANTHROPIC_API_KEY is not set — requests will fail until it is.") + srv = ThreadingHTTPServer((args.host, args.port), Handler) + print(f"{NAME} on http://{args.host}:{args.port} (model: {MODEL}, effort: {EFFORT})") + try: + srv.serve_forever() + except KeyboardInterrupt: + srv.shutdown() + + +if __name__ == "__main__": + main() diff --git a/apps/store/products/allerion-assistant/requirements.txt b/apps/store/products/allerion-assistant/requirements.txt new file mode 100644 index 0000000..e6315b0 --- /dev/null +++ b/apps/store/products/allerion-assistant/requirements.txt @@ -0,0 +1 @@ +anthropic>=0.69.0 diff --git a/apps/store/products/claude-skills/changelog-craft/SKILL.md b/apps/store/products/claude-skills/changelog-craft/SKILL.md new file mode 100644 index 0000000..4d6aa7e --- /dev/null +++ b/apps/store/products/claude-skills/changelog-craft/SKILL.md @@ -0,0 +1,72 @@ +--- +name: changelog-craft +description: >- + Turn raw git history into a clean, human-readable changelog or release notes. + Use when the user asks to write a changelog, draft release notes, summarize + commits since the last tag, or prepare a version bump. Groups changes by type, + rewrites terse commit subjects into user-facing prose, and flags breaking + changes. +license: Commercial — Allerion Systems. One seat per purchase. See LICENSE.txt. +--- + +# Changelog Craft + +Release notes that a *user* can read — not a dump of commit subjects. + +## When to use + +"Write a changelog", "draft release notes", "what changed since v1.2.0", +"summarize the commits for this release", "prep the version bump". + +## Workflow + +### 1. Collect the commit range +- Find the last release tag: `git describe --tags --abbrev=0` (or ask the user + for the base ref). +- Get the log in a parseable shape: + `git log ..HEAD --pretty=format:'%H%x09%s%x09%an'` +- Pipe it through `scripts/group_commits.py` to bucket commits by Conventional + Commit type (feat/fix/perf/docs/refactor/etc.) and surface anything marked + breaking (`!` or `BREAKING CHANGE`). + +### 2. Rewrite for the reader +Commit subjects are written for other developers; changelog entries are written +for users. For each entry: +- Lead with the user-visible effect, not the implementation. + `fix: nil deref in cache` → "Fixed a crash when the cache was empty." +- Drop internal-only churn (lint, formatting, CI tweaks) unless the user wants a + full technical log. +- Merge several commits that deliver one feature into a single entry. + +### 3. Structure the output +Use Keep a Changelog conventions unless the project clearly uses another: + +``` +## [1.3.0] — 2026-06-18 + +### ⚠ Breaking changes +- + +### Added +- + +### Fixed +- + +### Changed / Performance +- +``` + +### 4. Version suggestion +From the grouped commits, recommend a semver bump and say why: +- any breaking change → **major** +- any `feat` → **minor** +- only `fix`/`perf`/`docs` → **patch** +State the recommendation; let the user confirm. + +## Rules +- Never invent changes that aren't in the log. If a commit is cryptic, ask or + mark it `(needs description)` rather than guessing. +- Keep entries to one line where possible; link issue/PR numbers if present in + the subject. +- Put breaking changes first and make the migration step explicit. diff --git a/apps/store/products/claude-skills/changelog-craft/scripts/group_commits.py b/apps/store/products/claude-skills/changelog-craft/scripts/group_commits.py new file mode 100644 index 0000000..700189d --- /dev/null +++ b/apps/store/products/claude-skills/changelog-craft/scripts/group_commits.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Group git commits by Conventional Commit type and flag breaking changes. + +Input: lines of `HASHSUBJECTAUTHOR` (as produced by +`git log --pretty=format:'%H%x09%s%x09%an'`), from a file arg or stdin. + +Output: commits bucketed by type, a breaking-change list, and a suggested +semver bump. The model then rewrites each bucket into user-facing prose. + + git log v1.2.0..HEAD --pretty=format:'%H%x09%s%x09%an' | python3 group_commits.py +""" +from __future__ import annotations + +import re +import sys + +# Conventional Commit type -> changelog section heading. +SECTIONS = { + "feat": "Added", + "fix": "Fixed", + "perf": "Performance", + "refactor": "Changed", + "revert": "Changed", + "docs": "Documentation", + "build": "Build", + "ci": "CI", + "test": "Tests", + "chore": "Chores", + "style": "Style", +} +HEADER = re.compile(r"^(?P\w+)(?:\((?P[^)]+)\))?(?P!)?:\s*(?P.+)$") + + +def main() -> int: + src = open(sys.argv[1], encoding="utf-8", errors="replace") if len(sys.argv) > 1 else sys.stdin + buckets: dict[str, list[str]] = {} + breaking: list[str] = [] + other: list[str] = [] + feats = fixes = 0 + + for raw in src: + raw = raw.rstrip("\n") + if not raw.strip(): + continue + parts = raw.split("\t") + subject = parts[1] if len(parts) > 1 else parts[0] + short = (parts[0][:7]) if len(parts) > 1 else "" + m = HEADER.match(subject) + if not m: + other.append(f"{subject} ({short})" if short else subject) + continue + typ, scope, bang, desc = ( + m.group("type").lower(), m.group("scope"), m.group("bang"), m.group("desc")) + entry = f"{desc}" + (f" ({scope})" if scope else "") + (f" [{short}]" if short else "") + section = SECTIONS.get(typ, "Other") + buckets.setdefault(section, []).append(entry) + if bang or "BREAKING CHANGE" in subject: + breaking.append(entry) + if typ == "feat": + feats += 1 + elif typ == "fix": + fixes += 1 + + if not buckets and not other: + print("No commits on input.") + return 1 + + if breaking: + print("=== BREAKING CHANGES ===") + for e in breaking: + print(f" ! {e}") + print() + + order = ["Added", "Fixed", "Performance", "Changed", "Documentation", + "Build", "CI", "Tests", "Style", "Chores", "Other"] + for section in order: + items = buckets.get(section) + if not items: + continue + print(f"=== {section} ===") + for e in items: + print(f" - {e}") + print() + + if other: + print("=== Unconventional (rewrite or drop) ===") + for e in other: + print(f" - {e}") + print() + + bump = "major" if breaking else "minor" if feats else "patch" if fixes else "patch" + why = ("breaking change present" if breaking else + "new features present" if feats else + "fixes only" if fixes else "no feat/fix — likely patch or no release") + print(f"SUGGESTED BUMP: {bump} ({why})") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/store/products/claude-skills/pr-review-pro/SKILL.md b/apps/store/products/claude-skills/pr-review-pro/SKILL.md new file mode 100644 index 0000000..1d28050 --- /dev/null +++ b/apps/store/products/claude-skills/pr-review-pro/SKILL.md @@ -0,0 +1,84 @@ +--- +name: pr-review-pro +description: >- + Senior-engineer pull-request review. Use when the user asks to review a PR, + review a diff, check changes before merge, or asks "is this ready to ship". + Produces a triaged, severity-ranked review covering correctness, security, + tests, and maintainability — and tells the user plainly whether to merge. +license: Commercial — Allerion Systems. One seat per purchase. See LICENSE.txt. +--- + +# PR Review Pro + +A disciplined review pass that mirrors how a strong senior engineer reads a diff: +correctness first, then security, then the things that cost you later. + +## When to use + +Trigger this skill when the user wants a pull request, diff, or branch reviewed — +phrases like "review this PR", "check my changes", "anything wrong before I merge", +"look over this diff". + +## How to run the review + +Work in this order. Do **not** reorder — correctness bugs that ship are far more +expensive than style nits, so they come first and get the most attention. + +### 1. Establish the change's intent +- Read the PR title/description (or ask the user) for the *intended* behavior. +- Get the diff. If in a git repo: `git diff main...HEAD` (or the stated base). +- Run `scripts/diffstat.py` against the diff to get a size/risk profile before + reading line-by-line — it flags oversized diffs, test coverage gaps, and + high-risk file touches so you know where to spend attention. + +### 2. Correctness (highest priority) +For each changed hunk, ask: +- Does this do what the description claims? +- Off-by-one, null/undefined, empty-collection, and boundary cases? +- Error paths: are failures handled, or swallowed? Are resources released? +- Concurrency: shared state, races, ordering assumptions? +- Did a refactor change behavior that callers depend on? + +### 3. Security & data safety +- Untrusted input reaching a sink (SQL, shell, filesystem path, HTML, eval)? +- Secrets, tokens, or PII added to code, logs, or fixtures? +- AuthZ/AuthN checks present on new endpoints and state-changing actions? +- Dependency or supply-chain additions — are they necessary and pinned? + +### 4. Tests +- Is the new behavior covered? Would the tests fail if the change were reverted? +- Are edge cases from step 2 tested, not just the happy path? +- Flag assertions that can't fail (e.g. `assert True`, mocked-away logic). + +### 5. Maintainability (lowest priority — keep brief) +- Naming, dead code, duplicated logic, premature abstraction. +- Only raise these if they genuinely impede the next reader. Do not pad the + review with nits. + +## Output format + +Lead with the verdict, then the findings: + +``` +VERDICT: — one-line reason. + +BLOCKING + [correctness] file.py:42 — +SHOULD-FIX + [security] api.py:88 — +CONSIDER + [maintainability] util.py:12 — +``` + +Rules for the output: +- Every finding cites `file:line` and states the *fix*, not just the problem. +- Rank by severity (BLOCKING → SHOULD-FIX → CONSIDER), not by file order. +- If you're uncertain a finding is real, say so and put it in CONSIDER. +- If the diff is clean, say so plainly and merge — do not invent issues. + +## Calibration + +- A 4-line config change and a 400-line auth rewrite get *different* reviews. + Use the `diffstat.py` risk profile to scale depth. +- Prefer one precise blocking finding over ten speculative nits. +- Never approve code you could not explain back to the author. diff --git a/apps/store/products/claude-skills/pr-review-pro/scripts/diffstat.py b/apps/store/products/claude-skills/pr-review-pro/scripts/diffstat.py new file mode 100644 index 0000000..ef486da --- /dev/null +++ b/apps/store/products/claude-skills/pr-review-pro/scripts/diffstat.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Risk profile for a unified diff — read it before reviewing line-by-line. + +Reads a unified diff from a file argument or stdin and prints a compact risk +profile: per-file churn, whether tests moved alongside code, and heuristic flags +worth a closer look (debug statements, secrets, oversized hunks). + + git diff main...HEAD | python3 diffstat.py + python3 diffstat.py changes.diff +""" +from __future__ import annotations + +import re +import sys + +TEST_HINT = re.compile(r"(^|/)(tests?|spec)/|\.(test|spec)\.|_test\.|test_", re.I) +SECRET_HINT = re.compile( + r"(api[_-]?key|secret|password|passwd|token|private[_-]?key|" + r"aws_access_key_id|-----BEGIN)", re.I) +DEBUG_HINT = re.compile(r"\b(console\.log|println!|print\(|debugger|binding\.pry|fmt\.Println)\b") + + +def parse(diff: str): + files, cur = {}, None + for line in diff.splitlines(): + if line.startswith("diff --git") or line.startswith("+++ "): + m = re.search(r"[ab]/(\S+)$", line) or re.search(r"\+\+\+ b/(\S+)", line) + if m: + cur = m.group(1) + files.setdefault(cur, {"add": 0, "del": 0, "secrets": 0, "debug": 0}) + continue + if cur is None: + continue + if line.startswith("+") and not line.startswith("+++"): + files[cur]["add"] += 1 + body = line[1:] + if SECRET_HINT.search(body): + files[cur]["secrets"] += 1 + if DEBUG_HINT.search(body): + files[cur]["debug"] += 1 + elif line.startswith("-") and not line.startswith("---"): + files[cur]["del"] += 1 + return files + + +def main() -> int: + src = open(sys.argv[1], encoding="utf-8", errors="replace").read() if len(sys.argv) > 1 \ + else sys.stdin.read() + files = parse(src) + if not files: + print("No diff detected on input.") + return 1 + + total_add = sum(f["add"] for f in files.values()) + total_del = sum(f["del"] for f in files.values()) + has_tests = any(TEST_HINT.search(p) for p in files) + code_files = [p for p in files if not TEST_HINT.search(p)] + flags: list[str] = [] + + print(f"Files changed: {len(files)} +{total_add} / -{total_del}\n") + for path, s in sorted(files.items(), key=lambda kv: -(kv[1]["add"] + kv[1]["del"])): + mark = " (test)" if TEST_HINT.search(path) else "" + print(f" +{s['add']:<5} -{s['del']:<5} {path}{mark}") + if s["secrets"]: + flags.append(f"possible secret/credential added in {path} ({s['secrets']} line(s))") + if s["debug"]: + flags.append(f"debug statement(s) in {path} ({s['debug']})") + if s["add"] + s["del"] > 400: + flags.append(f"oversized change in {path} — consider splitting") + + if code_files and not has_tests: + flags.append("code changed but no test files touched — verify coverage") + if total_add + total_del > 800: + flags.append("large PR overall — review in sittings, watch for unrelated changes") + + print("\nRISK FLAGS" if flags else "\nNo automatic risk flags.") + for f in flags: + print(f" ! {f}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/store/products/claude-skills/test-forge/SKILL.md b/apps/store/products/claude-skills/test-forge/SKILL.md new file mode 100644 index 0000000..8615d15 --- /dev/null +++ b/apps/store/products/claude-skills/test-forge/SKILL.md @@ -0,0 +1,62 @@ +--- +name: test-forge +description: >- + Generate a focused, runnable test suite for a source file or function. Use + when the user asks to write tests, add unit tests, improve coverage, or "test + this". Maps the public surface, enumerates real edge cases, and writes tests + that would actually fail if the behavior regressed. +license: Commercial — Allerion Systems. One seat per purchase. See LICENSE.txt. +--- + +# Test Forge + +Tests that pin behavior — not tests that restate the implementation. + +## When to use + +"Write tests for this", "add unit tests", "improve coverage on X", "test this +function", "what cases am I missing". + +## Workflow + +### 1. Map the surface +- Identify the public functions/methods/classes to test (skip private helpers + unless they hold real logic). +- Run `scripts/surface.py ` to list callable definitions, their + parameters, and detected branch points (`if`, loops, `try`, early returns, + raises). Each branch is a case to cover. +- Detect the test framework already in use (pytest, unittest, jest, vitest, go + test, JUnit). Match it — do not introduce a new one. + +### 2. Enumerate cases before writing code +For each function, list cases across these axes — then write a test per case: +- **Happy path**: representative valid input → expected output. +- **Boundaries**: empty, single element, max, zero, negative, very large. +- **Invalid input**: wrong type, null/None, malformed — assert the error. +- **Branches**: every `if`/`else`/`except` from `surface.py` exercised. +- **Side effects**: files written, calls made, state mutated — assert them. + +### 3. Write tests that can fail +- Each test asserts a *specific* expected value, not just "no exception". +- Name tests for the case: `test_parse_rejects_empty_input`, not `test_parse_2`. +- Isolate external I/O (network, clock, filesystem, randomness) with the + framework's standard mocking/fixtures — but never mock away the logic under + test. +- Add a one-line comment on any non-obvious expected value explaining *why*. + +### 4. Verify +- Run the suite. It must pass against current code. +- Then sanity-check: would each test fail if the behavior were reverted? If a + test passes no matter what, it's worthless — rewrite or delete it. + +## Output +- A single test file in the project's framework and layout convention. +- A short note listing any cases you could *not* test and why (e.g. needs a live + DB), so the gap is visible rather than hidden. + +## Rules +- Coverage is a means, not the goal: one assertion that pins real behavior beats + ten that exercise lines without checking anything. +- Don't test third-party libraries or language built-ins. +- If the code is hard to test, say so and name the smallest refactor that would + make it testable — don't contort the test to fit untestable code. diff --git a/apps/store/products/claude-skills/test-forge/scripts/surface.py b/apps/store/products/claude-skills/test-forge/scripts/surface.py new file mode 100644 index 0000000..d448de6 --- /dev/null +++ b/apps/store/products/claude-skills/test-forge/scripts/surface.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""List the testable surface of a source file: callables, params, branch points. + +Supports Python precisely (via the `ast` module) and falls back to regex +heuristics for JS/TS/Go/Java/Ruby so the skill still gets a useful map of what +to test in any language. + + python3 surface.py path/to/module.py + python3 surface.py src/handler.ts +""" +from __future__ import annotations + +import ast +import re +import sys + + +def python_surface(src: str) -> list[str]: + out: list[str] = [] + try: + tree = ast.parse(src) + except SyntaxError as e: + return [f"(could not parse as Python: {e})"] + + branch_types = (ast.If, ast.For, ast.While, ast.Try, ast.With, + ast.BoolOp, ast.IfExp, ast.Raise, ast.Match) + + def describe(fn: ast.AST, qualname: str) -> str: + args = fn.args # type: ignore[attr-defined] + names = [a.arg for a in args.args if a.arg not in ("self", "cls")] + if args.vararg: + names.append("*" + args.vararg.arg) + if args.kwarg: + names.append("**" + args.kwarg.arg) + branches = sum(1 for n in ast.walk(fn) if isinstance(n, branch_types)) + raises = sum(1 for n in ast.walk(fn) if isinstance(n, ast.Raise)) + returns = sum(1 for n in ast.walk(fn) if isinstance(n, ast.Return)) + bits = [f"branches={branches}", f"raises={raises}", f"returns={returns}"] + return f" def {qualname}({', '.join(names)}) [{', '.join(bits)}]" + + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + if not node.name.startswith("_"): + out.append(describe(node, node.name)) + elif isinstance(node, ast.ClassDef): + out.append(f" class {node.name}") + for sub in node.body: + if isinstance(sub, (ast.FunctionDef, ast.AsyncFunctionDef)) \ + and not sub.name.startswith("_"): + out.append(describe(sub, f"{node.name}.{sub.name}")) + return out or [" (no public callables found)"] + + +DEF_PATTERNS = [ + re.compile(r"^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)"), + re.compile(r"^\s*(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s*)?\(([^)]*)\)\s*=>"), + re.compile(r"^\s*func\s+(?:\([^)]*\)\s*)?(\w+)\s*\(([^)]*)\)"), # Go + re.compile(r"^\s*def\s+(\w+)\s*\(([^)]*)\)"), # Ruby + re.compile(r"^\s*(?:public|protected|private)?\s*\w[\w<>\[\]]*\s+(\w+)\s*\(([^)]*)\)\s*\{"), # Java +] +BRANCH_RE = re.compile(r"\b(if|for|while|switch|case|catch|else)\b|\?\s*[^:]+:") + + +def generic_surface(src: str) -> list[str]: + out: list[str] = [] + seen: set[str] = set() + for line in src.splitlines(): + for pat in DEF_PATTERNS: + m = pat.match(line) + if m and m.group(1) not in seen and m.group(1) not in ("if", "for", "while", "switch"): + seen.add(m.group(1)) + params = ", ".join(p.strip() for p in m.group(2).split(",") if p.strip()) + out.append(f" {m.group(1)}({params})") + break + branches = len(BRANCH_RE.findall(src)) + out.append(f" [~{branches} branch point(s) across file — cover each]") + return out + + +def main() -> int: + if len(sys.argv) < 2: + print("usage: surface.py ", file=sys.stderr) + return 2 + path = sys.argv[1] + src = open(path, encoding="utf-8", errors="replace").read() + print(f"Testable surface of {path}:\n") + rows = python_surface(src) if path.endswith(".py") else generic_surface(src) + print("\n".join(rows)) + print("\nWrite one test per branch / boundary / error case listed above.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/store/products/codex-devkit/AGENTS.md b/apps/store/products/codex-devkit/AGENTS.md new file mode 100644 index 0000000..1e788bb --- /dev/null +++ b/apps/store/products/codex-devkit/AGENTS.md @@ -0,0 +1,22 @@ +# Allerion DevKit — agent guidance + +You have three local tools from the `allerion_devkit` MCP server. They are +deterministic and run on the user's machine — prefer them over guessing. + +- **Before reviewing a diff or committing**, call `review_diff` with the output + of `git diff` (or `git diff --staged`). Read the risk flags first, then review + the flagged files closely. Treat a "possible secret" flag as blocking until + confirmed otherwise. + +- **When asked for a changelog or release notes**, get commits with + `git log ..HEAD --pretty=format:'%H%x09%s'` and pass them to + `draft_changelog`. Use its sections and suggested bump as the skeleton, then + rewrite each line into user-facing prose. Put breaking changes first. + +- **When asked to write or improve tests**, pass the source file to + `scaffold_tests` to enumerate the callables and branch points, then write one + test per branch, boundary, and error case. Tests must assert specific values + and would fail if the behavior regressed. + +These tools summarize and map; you still do the judgment. Never paste secrets, +tokens, or customer data into tool arguments. diff --git a/apps/store/products/codex-devkit/README.md b/apps/store/products/codex-devkit/README.md new file mode 100644 index 0000000..c3d1f4e --- /dev/null +++ b/apps/store/products/codex-devkit/README.md @@ -0,0 +1,71 @@ +# Allerion DevKit — Codex plugin + +A Model Context Protocol (MCP) server that adds three deterministic, local-only +developer tools to **OpenAI Codex** (or any MCP client). No API key, no network +calls — everything runs on your machine. + +| Tool | What it does | +|------|--------------| +| `review_diff` | Risk-profiles a unified diff: per-file churn, missing tests, secrets, oversized hunks. | +| `draft_changelog` | Groups Conventional Commits into changelog sections and suggests a semver bump. | +| `scaffold_tests` | Maps a source file's testable surface (callables + branch counts) so you write one test per case. | + +## Requirements + +- Python 3.8+ (standard library only — nothing to `pip install`) +- OpenAI Codex CLI, or any MCP-compatible client + +## Install into Codex + +1. Note the absolute path to `server.py` in this folder. +2. Add the server to `~/.codex/config.toml`: + + ```toml + [mcp_servers.allerion_devkit] + command = "python3" + args = ["/absolute/path/to/codex-devkit/server.py"] + ``` + +3. Restart Codex. The tools appear as `allerion_devkit.review_diff`, + `allerion_devkit.draft_changelog`, and `allerion_devkit.scaffold_tests`. + +## Verify it works + +```bash +python3 server.py --selftest +# selftest OK — 3 tools, protocol handshake verified +``` + +You can also drive it by hand over stdio (newline-delimited JSON-RPC): + +```bash +printf '%s\n%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | python3 server.py +``` + +## Use it in Codex + +> "Review the staged diff before I commit." +> Codex calls `allerion_devkit.review_diff` with `git diff --staged` and reports +> the risk profile. + +> "Draft release notes from the commits since v1.2.0." +> Codex pipes `git log v1.2.0..HEAD --pretty=format:'%H%x09%s'` into +> `allerion_devkit.draft_changelog`. + +> "What should I test in `parser.py`?" +> Codex passes the file to `allerion_devkit.scaffold_tests` and writes tests for +> each branch. + +## What's included + +- `server.py` — the MCP server (single file, stdlib only) +- `config.example.toml` — copy-paste Codex config +- `AGENTS.md` — guidance Codex reads automatically to use the tools well +- `LICENSE.txt` — commercial license (one seat per purchase) + +## License + +Commercial license, one developer seat per purchase. See `LICENSE.txt`. +Sold by Allerion Systems — https://allerion.io diff --git a/apps/store/products/codex-devkit/config.example.toml b/apps/store/products/codex-devkit/config.example.toml new file mode 100644 index 0000000..70e4f31 --- /dev/null +++ b/apps/store/products/codex-devkit/config.example.toml @@ -0,0 +1,6 @@ +# Add this block to ~/.codex/config.toml to register Allerion DevKit with Codex. +# Replace the path with the absolute path to server.py on your machine. + +[mcp_servers.allerion_devkit] +command = "python3" +args = ["/absolute/path/to/codex-devkit/server.py"] diff --git a/apps/store/products/codex-devkit/server.py b/apps/store/products/codex-devkit/server.py new file mode 100644 index 0000000..50d492a --- /dev/null +++ b/apps/store/products/codex-devkit/server.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Allerion DevKit — an MCP server for OpenAI Codex (and any MCP client). + +Pure Python standard library. Speaks the Model Context Protocol over stdio +(newline-delimited JSON-RPC 2.0). Exposes three deterministic, local-only dev +tools — no network, no API key, nothing leaves the machine: + + review_diff risk-profile a unified diff (churn, missing tests, secrets) + draft_changelog group conventional commits + suggest a semver bump + scaffold_tests map a source file's testable surface (Python + heuristics) + +Install into Codex by adding to ~/.codex/config.toml: + + [mcp_servers.allerion_devkit] + command = "python3" + args = ["/abs/path/to/codex-devkit/server.py"] + +Then in Codex the tools appear as allerion_devkit.review_diff, etc. + +Run the built-in self-test (no MCP client needed): + + python3 server.py --selftest +""" +from __future__ import annotations + +import json +import re +import sys + +PROTOCOL_VERSION = "2025-06-18" +SERVER_INFO = {"name": "allerion-devkit", "version": "1.0.0"} + +# --------------------------------------------------------------------- tool impls +TEST_HINT = re.compile(r"(^|/)(tests?|spec)/|\.(test|spec)\.|_test\.|test_", re.I) +SECRET_HINT = re.compile( + r"(api[_-]?key|secret|password|passwd|token|private[_-]?key|" + r"aws_access_key_id|-----BEGIN)", re.I) +DEBUG_HINT = re.compile(r"\b(console\.log|println!|print\(|debugger|binding\.pry|fmt\.Println)\b") +CC_HEADER = re.compile(r"^(?P\w+)(?:\((?P[^)]+)\))?(?P!)?:\s*(?P.+)$") +CC_SECTIONS = { + "feat": "Added", "fix": "Fixed", "perf": "Performance", "refactor": "Changed", + "docs": "Documentation", "test": "Tests", "build": "Build", "ci": "CI", + "chore": "Chores", "style": "Style", "revert": "Changed", +} + + +def review_diff(diff: str) -> str: + files: dict[str, dict] = {} + cur = None + for line in diff.splitlines(): + if line.startswith("diff --git") or line.startswith("+++ "): + m = re.search(r"[ab]/(\S+)$", line) or re.search(r"\+\+\+ b/(\S+)", line) + if m: + cur = m.group(1) + files.setdefault(cur, {"add": 0, "del": 0, "secrets": 0, "debug": 0}) + continue + if cur is None: + continue + if line.startswith("+") and not line.startswith("+++"): + files[cur]["add"] += 1 + if SECRET_HINT.search(line[1:]): + files[cur]["secrets"] += 1 + if DEBUG_HINT.search(line[1:]): + files[cur]["debug"] += 1 + elif line.startswith("-") and not line.startswith("---"): + files[cur]["del"] += 1 + + if not files: + return "No diff detected on input." + + add = sum(f["add"] for f in files.values()) + dele = sum(f["del"] for f in files.values()) + has_tests = any(TEST_HINT.search(p) for p in files) + code_files = [p for p in files if not TEST_HINT.search(p)] + out = [f"Files changed: {len(files)} +{add} / -{dele}", ""] + flags: list[str] = [] + for path, s in sorted(files.items(), key=lambda kv: -(kv[1]["add"] + kv[1]["del"])): + mark = " (test)" if TEST_HINT.search(path) else "" + out.append(f" +{s['add']:<5} -{s['del']:<5} {path}{mark}") + if s["secrets"]: + flags.append(f"possible secret added in {path} ({s['secrets']} line(s))") + if s["debug"]: + flags.append(f"debug statement(s) in {path} ({s['debug']})") + if s["add"] + s["del"] > 400: + flags.append(f"oversized change in {path} — consider splitting") + if code_files and not has_tests: + flags.append("code changed but no test files touched — verify coverage") + if add + dele > 800: + flags.append("large PR overall — review in sittings") + out.append("") + out.append("RISK FLAGS" if flags else "No automatic risk flags.") + out += [f" ! {f}" for f in flags] + return "\n".join(out) + + +def draft_changelog(commits: str) -> str: + buckets: dict[str, list[str]] = {} + breaking: list[str] = [] + feats = fixes = 0 + for raw in commits.splitlines(): + if not raw.strip(): + continue + parts = raw.split("\t") + subject = parts[1] if len(parts) > 1 else parts[0] + short = parts[0][:7] if len(parts) > 1 else "" + m = CC_HEADER.match(subject) + if not m: + buckets.setdefault("Other", []).append(subject) + continue + typ = m.group("type").lower() + entry = m.group("desc") + (f" ({m.group('scope')})" if m.group("scope") else "") + entry += f" [{short}]" if short else "" + buckets.setdefault(CC_SECTIONS.get(typ, "Other"), []).append(entry) + if m.group("bang") or "BREAKING CHANGE" in subject: + breaking.append(entry) + feats += typ == "feat" + fixes += typ == "fix" + if not buckets: + return "No commits on input." + out: list[str] = [] + if breaking: + out += ["### ⚠ Breaking changes"] + [f"- {e}" for e in breaking] + [""] + for section in ["Added", "Fixed", "Performance", "Changed", "Documentation", + "Build", "CI", "Tests", "Style", "Chores", "Other"]: + if buckets.get(section): + out.append(f"### {section}") + out += [f"- {e}" for e in buckets[section]] + out.append("") + bump = "major" if breaking else "minor" if feats else "patch" + out.append(f"SUGGESTED BUMP: {bump}") + return "\n".join(out).rstrip() + + +def scaffold_tests(source: str, filename: str = "module.py") -> str: + if filename.endswith(".py"): + import ast + try: + tree = ast.parse(source) + except SyntaxError as e: + return f"(could not parse as Python: {e})" + rows = [] + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) \ + and not node.name.startswith("_"): + params = [a.arg for a in node.args.args if a.arg not in ("self", "cls")] + branches = sum(1 for n in ast.walk(node) + if isinstance(n, (ast.If, ast.For, ast.While, ast.Try, ast.Raise))) + rows.append(f" def {node.name}({', '.join(params)}) [branches={branches}]") + elif isinstance(node, ast.ClassDef): + rows.append(f" class {node.name}") + body = "\n".join(rows) or " (no public callables found)" + return ("Testable surface:\n" + body + + "\n\nWrite one test per branch / boundary / error case above " + "(happy path, empty, invalid input, each branch).") + # generic fallback + defs = re.findall(r"(?:function|func|def)\s+(\w+)\s*\(", source) + branches = len(re.findall(r"\b(if|for|while|switch|catch)\b", source)) + listing = "\n".join(f" {d}()" for d in dict.fromkeys(defs)) or " (no defs detected)" + return (f"Callables in {filename}:\n{listing}\n\n~{branches} branch point(s) — " + "cover each, plus boundaries and invalid input.") + + +TOOLS = { + "review_diff": { + "fn": lambda a: review_diff(a.get("diff", "")), + "description": "Risk-profile a unified diff: churn per file, missing tests, " + "secrets, oversized hunks. Run before reviewing line-by-line.", + "schema": {"type": "object", + "properties": {"diff": {"type": "string", + "description": "Unified diff text (git diff output)."}}, + "required": ["diff"]}, + }, + "draft_changelog": { + "fn": lambda a: draft_changelog(a.get("commits", "")), + "description": "Group Conventional Commits into changelog sections and suggest a " + "semver bump. Input: lines of 'HASHSUBJECT' from git log.", + "schema": {"type": "object", + "properties": {"commits": {"type": "string", + "description": "git log lines: HASHSUBJECT."}}, + "required": ["commits"]}, + }, + "scaffold_tests": { + "fn": lambda a: scaffold_tests(a.get("source", ""), a.get("filename", "module.py")), + "description": "Map a source file's testable surface (callables, branch counts) so " + "you can write one test per branch/boundary/error case.", + "schema": {"type": "object", + "properties": { + "source": {"type": "string", "description": "Source file contents."}, + "filename": {"type": "string", + "description": "Filename, used to pick the parser."}}, + "required": ["source"]}, + }, +} + + +# ----------------------------------------------------------------------- protocol +def handle(msg: dict) -> dict | None: + mid = msg.get("id") + method = msg.get("method") + if method == "initialize": + return _ok(mid, { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {"tools": {}}, + "serverInfo": SERVER_INFO, + }) + if method == "notifications/initialized" or method is None: + return None # notification, no response + if method == "tools/list": + return _ok(mid, {"tools": [ + {"name": n, "description": t["description"], "inputSchema": t["schema"]} + for n, t in TOOLS.items() + ]}) + if method == "tools/call": + params = msg.get("params") or {} + name = params.get("name") + args = params.get("arguments") or {} + tool = TOOLS.get(name) + if not tool: + return _err(mid, -32602, f"unknown tool: {name}") + try: + text = tool["fn"](args) + except Exception as e: # noqa: BLE001 — surface as a tool error, not a crash + return _ok(mid, {"content": [{"type": "text", "text": f"error: {e}"}], + "isError": True}) + return _ok(mid, {"content": [{"type": "text", "text": text}]}) + if method == "ping": + return _ok(mid, {}) + return _err(mid, -32601, f"method not found: {method}") + + +def _ok(mid, result): + return {"jsonrpc": "2.0", "id": mid, "result": result} + + +def _err(mid, code, message): + return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}} + + +def serve() -> None: + for line in sys.stdin: + line = line.strip() + if not line: + continue + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + resp = handle(msg) + if resp is not None: + sys.stdout.write(json.dumps(resp) + "\n") + sys.stdout.flush() + + +def selftest() -> int: + diff = ("diff --git a/app.py b/app.py\n+++ b/app.py\n" + "+print('debug')\n+api_key = 'x'\n-old = 1\n") + assert "RISK FLAGS" in review_diff(diff) + assert "secret" in review_diff(diff).lower() + cl = draft_changelog("abc1234\tfeat: add export\ndef5678\tfix!: drop old flag") + assert "Added" in cl and "Breaking" in cl and "major" in cl + surf = scaffold_tests("def add(a, b):\n if a:\n return a\n return b\n", "m.py") + assert "add" in surf and "branches=1" in surf + init = handle({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + assert init["result"]["serverInfo"]["name"] == "allerion-devkit" + listed = handle({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) + assert {t["name"] for t in listed["result"]["tools"]} == set(TOOLS) + called = handle({"jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": {"name": "review_diff", "arguments": {"diff": diff}}}) + assert called["result"]["content"][0]["text"] + print("selftest OK — 3 tools, protocol handshake verified") + return 0 + + +if __name__ == "__main__": + if "--selftest" in sys.argv: + raise SystemExit(selftest()) + serve() diff --git a/apps/store/server.py b/apps/store/server.py new file mode 100644 index 0000000..2fcc94a --- /dev/null +++ b/apps/store/server.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +"""Allerion Skills Store — sell Claude Skills + the Codex plugin via Stripe. + +Zero dependencies (Python standard library only). Serves a storefront, runs +one-time Stripe Checkout, verifies payment server-side, and delivers the product +as a license-gated zip built on the fly. + + python3 server.py # http://127.0.0.1:8088 + python3 server.py --port 9001 --host 0.0.0.0 + +Routes + GET / storefront (product grid) + GET /api/products catalog as JSON + GET /buy/ create Stripe Checkout + redirect (or dev-grant) + GET /success verify the paid session, mint license, show download + GET /download?token=... verify license, stream the product zip + GET /healthz health check + POST /webhook Stripe webhook (signed) — records sales durably +""" +from __future__ import annotations + +import argparse +import hashlib +import hmac +import html +import json +import os +import sqlite3 +import time +import urllib.parse +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import catalog +import fulfillment +import payments + +DB_PATH = os.environ.get("STORE_DB", os.path.join(os.path.dirname(__file__), "store.db")) +# Allow handing over a product without Stripe ONLY when explicitly enabled (local testing). +DEV_FULFILLMENT = os.environ.get("STORE_DEV_FULFILLMENT", "") == "1" +STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "").strip() +WEBHOOK_TOLERANCE = 300 # reject events whose timestamp is older than 5 minutes + + +# --------------------------------------------------------------------------- db +def db() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db() -> None: + with db() as conn: + conn.execute( + """CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL, + email TEXT, + amount INTEGER, + currency TEXT, + session_id TEXT UNIQUE, + source TEXT NOT NULL, -- 'stripe' | 'dev' + created_at TEXT NOT NULL, + license_token TEXT + )""" + ) + # Migration for existing DBs created before license_token existed. + try: + conn.execute("ALTER TABLE orders ADD COLUMN license_token TEXT") + except sqlite3.OperationalError: + pass # duplicate column — already migrated + + +def record_order(slug, email, amount, currency, session_id, source, license_token=None) -> None: + with db() as conn: + conn.execute( + "INSERT OR IGNORE INTO orders" + " (slug,email,amount,currency,session_id,source,created_at,license_token)" + " VALUES (?,?,?,?,?,?,?,?)", + (slug, email, amount, currency, session_id, source, + datetime.now(timezone.utc).isoformat(timespec="seconds"), license_token), + ) + + +def set_license_token(session_id, license_token) -> None: + """Persist the minted license token on an existing order row (idempotent).""" + with db() as conn: + conn.execute( + "UPDATE orders SET license_token=? WHERE session_id=? AND license_token IS NULL", + (license_token, session_id), + ) + + +def revenue() -> tuple[int, int]: + with db() as conn: + r = conn.execute( + "SELECT COUNT(*) n, COALESCE(SUM(amount),0) gross FROM orders WHERE source='stripe'" + ).fetchone() + return r["n"], r["gross"] + + +# ------------------------------------------------------------------- webhook auth +def verify_stripe_signature(payload: bytes, sig_header: str, secret: str, + tolerance: int = WEBHOOK_TOLERANCE, now: float | None = None) -> bool: + """Verify a Stripe webhook signature header (stdlib only). + + Header form: ``t=,v1=[,v1=...]``. We rebuild + ``.``, HMAC-SHA256 it with the endpoint secret, and accept if any + advertised v1 digest matches. Reject if the timestamp is outside `tolerance`. + """ + if not secret or not sig_header: + return False + parts = {} + v1 = [] + for item in sig_header.split(","): + k, _, v = item.partition("=") + k, v = k.strip(), v.strip() + if k == "v1": + v1.append(v) + elif k: + parts[k] = v + ts = parts.get("t") + if not ts or not v1: + return False + try: + ts_int = int(ts) + except ValueError: + return False + if tolerance: + current = time.time() if now is None else now + if abs(current - ts_int) > tolerance: + return False + signed_payload = f"{ts}.{payload.decode('utf-8', 'replace')}" + expected = hmac.new(secret.encode(), signed_payload.encode(), hashlib.sha256).hexdigest() + return any(hmac.compare_digest(expected, candidate) for candidate in v1) + + +# ------------------------------------------------------------------------- views +CSS = """ +:root{--bg:#07090a;--bg2:#0b0f0c;--panel:#0f1411;--line:#1f2a22;--line2:#2b3a2e; +--ink:#e9f1ea;--mut:#7e8d82;--nv:#76b900;--nv2:#b6ff3a;--glow:rgba(118,185,0,.30); +--mono:ui-monospace,"JetBrains Mono",Menlo,Consolas,monospace} +*{box-sizing:border-box}html{scroll-behavior:smooth} +body{margin:0;background:var(--bg);color:var(--ink); +font:16px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif} +body::before{content:"";position:fixed;inset:0;z-index:-2; +background:radial-gradient(900px 520px at 50% -12%,rgba(118,185,0,.12),transparent 70%),linear-gradient(var(--bg2),var(--bg))} +a{color:var(--nv);text-decoration:none} +.wrap{max-width:1040px;margin:0 auto;padding:0 24px} +.mono{font-family:var(--mono);text-transform:uppercase;letter-spacing:.18em;font-size:12px;color:var(--mut)} +.nv{color:var(--nv)} +header{position:sticky;top:0;z-index:10;backdrop-filter:blur(10px); +background:rgba(7,9,10,.72);border-bottom:1px solid var(--line)} +header .wrap{display:flex;align-items:center;justify-content:space-between;height:62px} +.brand{font-family:var(--mono);font-weight:700;letter-spacing:.22em;font-size:14px} +.brand .mk{color:var(--nv)} +nav a{margin-left:22px;font-family:var(--mono);font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--mut)} +nav a:hover{color:var(--ink)} +.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--nv); +box-shadow:0 0 0 0 var(--glow);animation:pulse 2s infinite} +@keyframes pulse{0%{box-shadow:0 0 0 0 var(--glow)}70%{box-shadow:0 0 0 10px transparent}100%{box-shadow:0 0 0 0 transparent}} +.hero{padding:84px 0 36px;text-align:center} +.hero .ey{display:inline-flex;align-items:center;gap:10px;margin-bottom:22px;padding:6px 14px; +border:1px solid var(--line2);border-radius:999px;background:var(--panel)} +.hero h1{font-size:50px;line-height:1.04;margin:0 0 16px;letter-spacing:-.025em;font-weight:800} +.hero h1 .g{background:linear-gradient(90deg,var(--nv),var(--nv2));-webkit-background-clip:text;background-clip:text;color:transparent} +.hero p{font-size:18px;color:var(--mut);max-width:640px;margin:0 auto} +section .lbl{display:flex;align-items:center;gap:14px;margin:54px 0 20px;font-family:var(--mono); +text-transform:uppercase;letter-spacing:.18em;font-size:12px;color:var(--mut)} +section .lbl::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--line2),transparent)} +.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px} +.card{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:12px; +padding:24px;transition:border-color .2s,transform .2s,box-shadow .2s;overflow:hidden;display:flex;flex-direction:column} +.card::before{content:"";position:absolute;left:0;top:0;width:30px;height:30px; +border-top:2px solid var(--nv);border-left:2px solid var(--nv);opacity:.45;transition:opacity .2s} +.card:hover{border-color:var(--line2);transform:translateY(-3px);box-shadow:0 14px 44px rgba(0,0,0,.55)} +.card:hover::before{opacity:1} +.card.feat{border-color:var(--nv);box-shadow:0 0 28px var(--glow)} +.kind{font-family:var(--mono);font-size:11px;color:var(--nv);letter-spacing:.16em} +.card h3{margin:8px 0 4px;font-size:20px} +.card .tag{color:var(--ink);font-size:14px;margin:0 0 10px} +.card p{margin:0 0 16px;color:var(--mut);font-size:14px;flex:1} +.row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:auto} +.amt{font-size:26px;font-weight:800} +.amt small{font-size:12px;color:var(--mut);font-weight:600;font-family:var(--mono);text-transform:uppercase} +.btn{display:inline-block;font-family:var(--mono);font-size:13px;letter-spacing:.1em;text-transform:uppercase; +background:var(--nv);color:#06140a;padding:11px 20px;border-radius:8px;font-weight:700;border:1px solid var(--nv); +transition:transform .15s,box-shadow .15s,background .15s;box-shadow:0 0 24px var(--glow);cursor:pointer} +.btn:hover{transform:translateY(-2px);box-shadow:0 0 38px var(--glow);background:var(--nv2);border-color:var(--nv2)} +.btn.ghost{background:transparent;color:var(--ink);border-color:var(--line2);box-shadow:none} +.btn.big{font-size:15px;padding:14px 26px} +.telem{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:22px 0} +.telem .t{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px} +.telem .k{font-family:var(--mono);font-size:11px;color:var(--mut);letter-spacing:.14em;text-transform:uppercase} +.telem .v{font-size:24px;font-weight:800;margin-top:4px} +.banner{background:var(--panel);border:1px solid var(--line2);border-radius:12px;padding:16px 18px;margin:18px 0;font-size:14px;color:var(--mut)} +.banner b{color:var(--ink)} +footer{border-top:1px solid var(--line);color:var(--mut);padding:30px 0;text-align:center;margin-top:60px} +.note{color:var(--mut);font-size:12px;font-family:var(--mono);letter-spacing:.04em} +code{font-family:var(--mono);color:var(--nv);font-size:13px} +.center{text-align:center} +""" + + +def page(title: str, body: str) -> bytes: + doc = f""" + +{html.escape(title)} +
+
ALLERION SKILLS
+ +
+{body} +
Allerion Systems — developer skills for Claude & Codex
+
Instant download · commercial license · one seat per purchase
+""" + return doc.encode() + + +def storefront() -> bytes: + cards = [] + for slug, p in catalog.PRODUCTS.items(): + feat = " feat" if slug == "devkit-bundle" else "" + cta = (f'Buy · {catalog.usd(p["price"])} →') + cards.append( + f'
' + f'
{html.escape(p["kind"])}
' + f'

{html.escape(p["name"])}

' + f'
{html.escape(p["tagline"])}
' + f'

{html.escape(p["blurb"])}

' + f'
{catalog.usd(p["price"])} ' + f'one-time
{cta}
' + ) + grid = "".join(cards) + + n, gross = revenue() + if payments.live(): + mode = "Stripe live" if "sk_live" in payments.STRIPE_API_KEY else "Stripe test mode" + banner = (f'') + else: + banner = ('') + + body = f""" +
+
Developer skills · instant download
+

Senior-engineer skills for
Claude & Codex.

+

Battle-tested Agent Skills and a Codex plugin that review your diffs, write your + release notes, and forge your tests. Buy once, download instantly, own it.

+
+
+ {banner} +
+
Products
{len(catalog.PRODUCTS)}
+
Works in
Claude + Codex
+
Orders fulfilled
{n}
+
+
// Catalog
+
{grid}
+
// How it works
+ +
""" + return page("Allerion Skills — developer skills for Claude & Codex", body) + + +def checkout_pending_page(slug: str) -> bytes: + p = catalog.PRODUCTS[slug] + dev = "" + if DEV_FULFILLMENT: + dev = (f'

' + f'Dev grant (local testing) →

' + f'

// STORE_DEV_FULFILLMENT=1 — local testing only, no payment

') + return page("Checkout — configuring", f""" +
+
Checkout · pending key
+

Ready for {html.escape(p['name'])}.

+

Stripe Checkout for this product is wired and ready — it needs the Stripe secret key + set as STRIPE_API_KEY. Once it's in the environment, this button opens + Stripe Checkout and delivers the download automatically.

+ Back to catalog{dev} +
""") + + +def download_page(slug: str, email: str, token: str) -> bytes: + p = catalog.PRODUCTS[slug] + url = "/download?" + urllib.parse.urlencode({"token": token}) + return page("Your download", f""" +
+
Payment confirmed
+

Thank you — here's {html.escape(p['name'])}.

+

Licensed to {html.escape(email or 'you')}. Your download link is below; the + license key is inside the zip.

+

Download .zip →

+

// keep this link — it stays valid for re-downloads

+

Back to store

+
""") + + +def error_page(title: str, detail: str, code: int) -> bytes: + return page(title, f""" +

{html.escape(title)}

+

{html.escape(detail)[:400]}

+

Back to catalog

+
""") + + +# ----------------------------------------------------------------------- handler +class Handler(BaseHTTPRequestHandler): + server_version = "AllerionStore/1.0" + + def log_message(self, *a): + pass + + def _send(self, code, body, ctype="text/html; charset=utf-8", extra=None): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + for k, v in (extra or {}).items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(body) + + def _json(self, code, obj): + self._send(code, json.dumps(obj, indent=2).encode(), "application/json") + + def _redirect(self, location): + self.send_response(303) + self.send_header("Location", location) + self.end_headers() + + def do_GET(self): + u = urllib.parse.urlparse(self.path) + path = u.path + q = urllib.parse.parse_qs(u.query) + if path == "/": + self._send(200, storefront()) + elif path == "/api/products": + self._json(200, {"products": [ + {"slug": s, "name": p["name"], "kind": p["kind"], + "price_cents": p["price"], "price": catalog.usd(p["price"]), + "tagline": p["tagline"]} + for s, p in catalog.PRODUCTS.items()]}) + elif path.startswith("/buy/"): + self._buy(path.split("/", 2)[2]) + elif path == "/success": + self._success(q) + elif path == "/download": + self._download(q) + elif path == "/healthz": + n, gross = revenue() + self._json(200, {"ok": True, "stripe_live": payments.live(), + "signing_production": fulfillment.signing_is_production(), + "products": len(catalog.PRODUCTS), + "orders": n, "gross_cents": gross}) + else: + self._send(404, error_page("404", "No such page.", 404)) + + def do_POST(self): + path = urllib.parse.urlparse(self.path).path + if path == "/webhook": + self._webhook() + else: + self._json(404, {"error": "not found"}) + + def _webhook(self): + if not STRIPE_WEBHOOK_SECRET: + return self._json(503, {"error": "webhook not configured", + "detail": "STRIPE_WEBHOOK_SECRET is not set."}) + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"" + sig = self.headers.get("Stripe-Signature", "") + if not verify_stripe_signature(raw, sig, STRIPE_WEBHOOK_SECRET): + return self._json(400, {"error": "invalid signature"}) + try: + event = json.loads(raw) + except (ValueError, json.JSONDecodeError): + return self._json(400, {"error": "invalid payload"}) + + if event.get("type") != "checkout.session.completed": + return self._json(200, {"ignored": event.get("type")}) + + session = (event.get("data") or {}).get("object") or {} + if session.get("payment_status") != "paid": + return self._json(200, {"ignored": "unpaid"}) + + slug = (session.get("metadata") or {}).get("slug", "") + if slug not in catalog.PRODUCTS: + return self._json(200, {"ignored": "unknown product"}) + email = (session.get("customer_details") or {}).get("email") \ + or session.get("customer_email") or "" + session_id = session.get("id") or "" + token = fulfillment.mint_license(slug, email) + # Idempotent: INSERT OR IGNORE on the UNIQUE session_id; the success page may + # have recorded the row already, in which case we just backfill the token. + record_order(slug, email, session.get("amount_total"), + session.get("currency"), session_id, "stripe", license_token=token) + set_license_token(session_id, token) + return self._json(200, {"received": True, "slug": slug}) + + def _buy(self, slug): + if slug not in catalog.PRODUCTS: + return self._send(404, error_page("Unknown product", "No such item.", 404)) + if not payments.live(): + return self._send(200, checkout_pending_page(slug)) + try: + session = payments.create_checkout(slug) + except Exception as e: # noqa: BLE001 + return self._send(502, error_page("Checkout unavailable", str(e), 502)) + self._redirect(session["url"]) + + def _success(self, q): + # Dev grant path (local testing only). + if q.get("dev", [""])[0] == "1" and DEV_FULFILLMENT: + slug = q.get("slug", [""])[0] + if slug not in catalog.PRODUCTS: + return self._send(404, error_page("Unknown product", "No such item.", 404)) + email = "dev@localhost" + record_order(slug, email, 0, "usd", f"dev-{slug}-{datetime.now().timestamp()}", "dev") + token = fulfillment.mint_license(slug, email) + return self._send(200, download_page(slug, email, token)) + + session_id = q.get("session_id", [""])[0] + if not session_id: + return self._send(400, error_page("Missing session", "No checkout session id.", 400)) + try: + session = payments.retrieve_session(session_id) + except Exception as e: # noqa: BLE001 + return self._send(502, error_page("Could not verify payment", str(e), 502)) + if session.get("payment_status") != "paid": + return self._send(402, error_page("Payment not completed", + "This checkout session isn't paid.", 402)) + slug = (session.get("metadata") or {}).get("slug", "") + if slug not in catalog.PRODUCTS: + return self._send(400, error_page("Unknown product", "Session has no product.", 400)) + email = (session.get("customer_details") or {}).get("email") \ + or session.get("customer_email") or "" + token = fulfillment.mint_license(slug, email) + record_order(slug, email, session.get("amount_total"), + session.get("currency"), session_id, "stripe", license_token=token) + set_license_token(session_id, token) + self._send(200, download_page(slug, email, token)) + + def _download(self, q): + token = q.get("token", [""])[0] + payload = fulfillment.verify_license(token) + if not payload: + return self._send(403, error_page("Invalid or expired link", + "This download link could not be verified.", 403)) + slug, email = payload["slug"], payload.get("email", "") + try: + blob = fulfillment.build_zip(slug, email, token) + except Exception as e: # noqa: BLE001 + return self._send(500, error_page("Build failed", str(e), 500)) + fname = f"allerion-{slug}.zip" + self._send(200, blob, "application/zip", + {"Content-Disposition": f'attachment; filename="{fname}"'}) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--port", type=int, default=8088) + ap.add_argument("--host", default="127.0.0.1") + args = ap.parse_args() + init_db() + if not fulfillment.signing_is_production(): + print("WARNING: STORE_SIGNING_SECRET not set — using a dev key. " + "Set it before going live or license links won't be secure.") + srv = ThreadingHTTPServer((args.host, args.port), Handler) + print(f"Allerion Skills Store on http://{args.host}:{args.port} " + f"(Stripe {'LIVE' if payments.live() else 'not configured'})") + try: + srv.serve_forever() + except KeyboardInterrupt: + srv.shutdown() + + +if __name__ == "__main__": + main() diff --git a/apps/store/setup_stripe.py b/apps/store/setup_stripe.py new file mode 100644 index 0000000..104188b --- /dev/null +++ b/apps/store/setup_stripe.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""One-time helper: create Stripe Products + one-time Prices for the catalog. + +Run once with your Stripe secret key to mint a Price for each catalog item, then +export the printed STRIPE_PRICE_* env vars so the store uses stable price ids +(instead of inline price_data). Idempotency is on you — running twice creates +duplicate prices in Stripe. + + STRIPE_API_KEY=sk_test_... python3 setup_stripe.py + +Stdlib only. +""" +from __future__ import annotations + +import json +import os +import urllib.parse +import urllib.request + +import catalog + +KEY = os.environ.get("STRIPE_API_KEY", "").strip() +API = "https://api.stripe.com/v1" + + +def _post(path: str, params: list[tuple[str, str]]) -> dict: + req = urllib.request.Request( + f"{API}{path}", data=urllib.parse.urlencode(params).encode(), + headers={"Authorization": f"Bearer {KEY}", + "Content-Type": "application/x-www-form-urlencoded"}, method="POST") + with urllib.request.urlopen(req, timeout=20) as r: + return json.loads(r.read()) + + +def main() -> int: + if not KEY: + print("Set STRIPE_API_KEY first.") + return 1 + print("Creating Stripe products + prices...\n") + print("# Export these so the store uses stable Price ids:") + for slug, p in catalog.PRODUCTS.items(): + product = _post("/products", [("name", p["name"]), + ("description", p["tagline"])]) + price = _post("/prices", [ + ("product", product["id"]), + ("unit_amount", str(p["price"])), + ("currency", "usd"), + ]) + print(f'export {p["price_env"]}={price["id"]} # {p["name"]} {catalog.usd(p["price"])}') + print("\nDone. Add the exports to your environment and restart the store.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/store/tests/test_store.py b/apps/store/tests/test_store.py new file mode 100644 index 0000000..de9c2ad --- /dev/null +++ b/apps/store/tests/test_store.py @@ -0,0 +1,216 @@ +"""Tests for the Allerion Skills Store: catalog, license signing, zip delivery.""" +import hashlib +import hmac +import io +import json +import os +import sys +import time +import zipfile + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import catalog # noqa: E402 +import fulfillment # noqa: E402 + + +def test_catalog_prices_and_sources_exist(): + here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + assert catalog.PRODUCTS, "catalog must not be empty" + for slug, p in catalog.PRODUCTS.items(): + assert p["price"] > 0 + assert p["sources"] + for src in p["sources"]: + assert os.path.isdir(os.path.join(here, src)), f"{slug}: missing source {src}" + + +def test_usd_formatting(): + assert catalog.usd(4900) == "$49" + assert catalog.usd(2999) == "$29.99" + assert catalog.usd(14900) == "$149" + + +def test_license_roundtrip_and_tamper(): + token = fulfillment.mint_license("pr-review-pro", "buyer@acme.co") + payload = fulfillment.verify_license(token) + assert payload and payload["slug"] == "pr-review-pro" + assert payload["email"] == "buyer@acme.co" + # tampering with the payload invalidates the signature + body, sig = token.split(".", 1) + forged = fulfillment._b64(b'{"slug":"devkit-bundle","email":"x","ts":"now"}') + "." + sig + assert fulfillment.verify_license(forged) is None + # garbage is rejected + assert fulfillment.verify_license("not-a-token") is None + + +def test_license_unknown_slug_rejected(): + # a perfectly-signed token for a product that doesn't exist must be refused + tok = fulfillment.mint_license("pr-review-pro", "x@y.co") + raw = fulfillment._unb64(tok.split(".")[0]).replace(b"pr-review-pro", b"ghost-product") + import hashlib, hmac + sig = hmac.new(fulfillment.SIGNING_SECRET.encode(), raw, hashlib.sha256).digest() + forged = fulfillment._b64(raw) + "." + fulfillment._b64(sig) + assert fulfillment.verify_license(forged) is None + + +def test_build_zip_contains_skill_and_license(): + token = fulfillment.mint_license("pr-review-pro", "buyer@acme.co") + blob = fulfillment.build_zip("pr-review-pro", "buyer@acme.co", token) + zf = zipfile.ZipFile(io.BytesIO(blob)) + names = zf.namelist() + assert "pr-review-pro/SKILL.md" in names + assert "pr-review-pro/LICENSE.txt" in names + assert "README-FIRST.txt" in names + # license is personalized to the buyer + lic = zf.read("pr-review-pro/LICENSE.txt").decode() + assert "buyer@acme.co" in lic + # no pyc / cache junk leaked in + assert not any(n.endswith(".pyc") or "__pycache__" in n for n in names) + + +def test_bundle_zip_contains_all_products(): + token = fulfillment.mint_license("devkit-bundle", "team@acme.co") + blob = fulfillment.build_zip("devkit-bundle", "team@acme.co", token) + names = zipfile.ZipFile(io.BytesIO(blob)).namelist() + assert "pr-review-pro/SKILL.md" in names + assert "changelog-craft/SKILL.md" in names + assert "test-forge/SKILL.md" in names + assert "codex-devkit/server.py" in names + + +def test_assistant_zip_contains_app_and_requirements(): + token = fulfillment.mint_license("allerion-assistant", "buyer@acme.co") + blob = fulfillment.build_zip("allerion-assistant", "buyer@acme.co", token) + names = zipfile.ZipFile(io.BytesIO(blob)).namelist() + assert "allerion-assistant/assistant.py" in names + assert "allerion-assistant/requirements.txt" in names + assert "allerion-assistant/LICENSE.txt" in names + + +def test_assistant_app_compiles(): + import py_compile + here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + py_compile.compile( + os.path.join(here, "products", "allerion-assistant", "assistant.py"), + doraise=True, + ) + + +def test_every_claude_skill_has_valid_frontmatter(): + here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + skills_dir = os.path.join(here, "products", "claude-skills") + for name in os.listdir(skills_dir): + skill_md = os.path.join(skills_dir, name, "SKILL.md") + assert os.path.isfile(skill_md), f"{name} missing SKILL.md" + text = open(skill_md, encoding="utf-8").read() + assert text.startswith("---"), f"{name}: SKILL.md needs YAML frontmatter" + front = text.split("---", 2)[1] + assert "name:" in front and "description:" in front, f"{name}: frontmatter incomplete" + + +# ---------------------------------------------------------------- webhook tests +_WEBHOOK_SECRET = "whsec_test_secret" + + +def _sign(payload: bytes, secret: str, ts: int) -> str: + signed = f"{ts}.{payload.decode()}".encode() + digest = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() + return f"t={ts},v1={digest}" + + +def _fresh_server(tmp_path, monkeypatch): + """Import server.py with an isolated DB and webhook secret set.""" + monkeypatch.setenv("STORE_DB", str(tmp_path / "store.db")) + monkeypatch.setenv("STRIPE_WEBHOOK_SECRET", _WEBHOOK_SECRET) + for mod in ("server",): + sys.modules.pop(mod, None) + import importlib + server = importlib.import_module("server") + importlib.reload(server) + server.init_db() + return server + + +def test_verify_stripe_signature_good_and_bad(): + server = _fresh_server_module() + payload = b'{"hello":"world"}' + ts = int(time.time()) + header = _sign(payload, _WEBHOOK_SECRET, ts) + assert server.verify_stripe_signature(payload, header, _WEBHOOK_SECRET) + # bad signature (wrong secret) + bad = _sign(payload, "whsec_wrong", ts) + assert not server.verify_stripe_signature(payload, bad, _WEBHOOK_SECRET) + # multiple v1 entries — any match accepted + multi = f"t={ts},v1=deadbeef,{header.split(',', 1)[1]}" + assert server.verify_stripe_signature(payload, multi, _WEBHOOK_SECRET) + + +def test_verify_stripe_signature_stale_rejected(): + server = _fresh_server_module() + payload = b'{"hello":"world"}' + stale_ts = int(time.time()) - 600 # 10 minutes ago + header = _sign(payload, _WEBHOOK_SECRET, stale_ts) + assert not server.verify_stripe_signature(payload, header, _WEBHOOK_SECRET) + # but valid when within tolerance + fresh = _sign(payload, _WEBHOOK_SECRET, int(time.time())) + assert server.verify_stripe_signature(payload, fresh, _WEBHOOK_SECRET) + + +def _fresh_server_module(): + """server.py imported once with the webhook secret available (shared DB ok).""" + os.environ["STRIPE_WEBHOOK_SECRET"] = _WEBHOOK_SECRET + sys.modules.pop("server", None) + import importlib + server = importlib.import_module("server") + importlib.reload(server) + return server + + +def test_webhook_records_order_and_token(tmp_path, monkeypatch): + server = _fresh_server(tmp_path, monkeypatch) + event = { + "type": "checkout.session.completed", + "data": {"object": { + "id": "cs_test_webhook_1", + "payment_status": "paid", + "amount_total": 4900, + "currency": "usd", + "metadata": {"slug": "pr-review-pro"}, + "customer_details": {"email": "buyer@webhook.co"}, + }}, + } + raw = json.dumps(event).encode() + ts = int(time.time()) + header = _sign(raw, _WEBHOOK_SECRET, ts) + assert server.verify_stripe_signature(raw, header, _WEBHOOK_SECRET) + + # Drive the handler logic directly (mirror of _webhook body) for an end-to-end check. + session = event["data"]["object"] + slug = session["metadata"]["slug"] + email = session["customer_details"]["email"] + token = fulfillment.mint_license(slug, email) + server.record_order(slug, email, session["amount_total"], session["currency"], + session["id"], "stripe", license_token=token) + server.set_license_token(session["id"], token) + + with server.db() as conn: + row = conn.execute( + "SELECT slug,email,license_token FROM orders WHERE session_id=?", + (session["id"],)).fetchone() + assert row is not None + assert row["slug"] == "pr-review-pro" + assert row["email"] == "buyer@webhook.co" + assert row["license_token"] == token + assert fulfillment.verify_license(row["license_token"])["slug"] == "pr-review-pro" + + +def test_webhook_idempotent_record(tmp_path, monkeypatch): + server = _fresh_server(tmp_path, monkeypatch) + sid = "cs_test_idem" + t1 = fulfillment.mint_license("pr-review-pro", "a@b.co") + server.record_order("pr-review-pro", "a@b.co", 4900, "usd", sid, "stripe", license_token=t1) + # second insert with same session_id is ignored (UNIQUE constraint) + server.record_order("pr-review-pro", "a@b.co", 4900, "usd", sid, "stripe", license_token=t1) + with server.db() as conn: + n = conn.execute("SELECT COUNT(*) c FROM orders WHERE session_id=?", (sid,)).fetchone()["c"] + assert n == 1 diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 0000000..743ef37 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,28 @@ +# Copy to .env on the VPS and fill in. -> cp .env.example .env + +# --- Public URLs (must match your DNS) --------------------------------------- +PLATFORM_BASE_URL=https://allerion.io +STORE_BASE_URL=https://skills.allerion.io +STORE_URL=https://skills.allerion.io +ACME_EMAIL=you@allerion.io + +# --- Required: license signing for the store --------------------------------- +# Generate once and keep it secret/stable: openssl rand -hex 32 +STORE_SIGNING_SECRET= + +# --- Stripe (leave blank to run as a preview with checkout disabled) --------- +# sk_test_... to rehearse with test cards; sk_live_... to take real money. +STRIPE_API_KEY= + +# Stripe webhook signing secret (Developers → Webhooks → your endpoint). +# Makes sales record durably even if the buyer closes the success page. +STRIPE_WEBHOOK_SECRET= + +# --- Optional: stable Stripe Price ids (from: python3 apps/store/setup_stripe.py) +STRIPE_PRICE_TEAM= +STRIPE_PRICE_PR_REVIEW_PRO= +STRIPE_PRICE_CHANGELOG_CRAFT= +STRIPE_PRICE_TEST_FORGE= +STRIPE_PRICE_CODEX_DEVKIT= +STRIPE_PRICE_DEVKIT_BUNDLE= +STRIPE_PRICE_ALLERION_ASSISTANT= diff --git a/deploy/.gitignore b/deploy/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/deploy/.gitignore @@ -0,0 +1 @@ +.env diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..8b07416 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,32 @@ +# Allerion — single Caddy fronting the customer-facing web with automatic HTTPS. +# Caddy provisions and renews Let's Encrypt certs for every hostname below, +# as long as their DNS A records point at this VPS and ports 80/443 are open. + +{ + # Email for Let's Encrypt expiry notices (override via .env -> compose). + email {$ACME_EMAIL:admin@allerion.io} +} + +# Marketing site + embedded CRM +allerion.io, www.allerion.io { + encode gzip + reverse_proxy platform:8099 +} + +# Self-serve skills store (Stripe checkout + license-gated downloads) +skills.allerion.io { + encode gzip + reverse_proxy store:8088 +} + +# --- Optional: serve the AI gateway from this same Caddy --------------------- +# If you already run infra/ai-gateway with its OWN Caddy, only ONE process can +# bind ports 80/443 on this box. Easiest fix: stop the gateway's standalone +# Caddy, point its LiteLLM container at this network, and uncomment this block +# so a single Caddy serves everything. (LiteLLM streams, so keep flush_interval.) +# +# api.allerion.io { +# reverse_proxy litellm:4000 { +# flush_interval -1 +# } +# } diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md new file mode 100644 index 0000000..0ef2b14 --- /dev/null +++ b/deploy/DEPLOY.md @@ -0,0 +1,141 @@ +# Deploy Allerion to your VPS + +One Docker stack puts the **marketing site + CRM** on `allerion.io` and the +**skills store** on `skills.allerion.io`, behind Caddy with automatic HTTPS. +From a clean VPS, going live is ~10 minutes. + +## 0. Prerequisites + +- A VPS with a public IP, ports **80** and **443** open in the firewall. +- The `allerion.io` domain (you have it). +- Docker + the Compose plugin on the VPS: + ```bash + curl -fsSL https://get.docker.com | sh + ``` + +## 1. Point DNS at the VPS + +Create **A records** for each hostname → your VPS public IP: + +| Host | Type | Value | +|------|------|-------| +| `allerion.io` | A | `` | +| `www.allerion.io` | A | `` | +| `skills.allerion.io` | A | `` | + +(If you also run the AI gateway, `api.allerion.io` → same IP — see step 6.) +Wait for DNS to propagate (`dig +short allerion.io` should return your IP). +Caddy can't issue certificates until DNS resolves to this box. + +## 2. Get the code on the VPS + +```bash +git clone https://github.com/allerion-systems/.github.git allerion +cd allerion +git checkout claude/nvidia-ai-models-access-mmn5n5 # until this is merged to main +cd deploy +``` + +## 3. Configure + +```bash +cp .env.example .env +# generate a strong, STABLE signing secret (don't change it after launch): +echo "STORE_SIGNING_SECRET=$(openssl rand -hex 32)" >> .env +nano .env # set ACME_EMAIL, and STRIPE_API_KEY when ready (see step 5) +``` + +Leaving `STRIPE_API_KEY` blank is fine for a first boot — the store comes up in +**preview** mode (browsable, checkout shows "configuring"). Add the key when +you're ready to charge. + +## 4. Launch + +Pre-flight first (checks docker/compose, `.env` secrets, DNS, and ports 80/443): + +```bash +./preflight.sh +``` + +Then bring the stack up, and once it's running confirm both sites are healthy: + +```bash +make up # = docker compose up -d --build (see `make help`) +docker compose logs -f caddy # watch it obtain TLS certs (Ctrl-C to stop tailing) +./smoke.sh # checks https://allerion.io/healthz + https://skills.allerion.io/healthz +``` + +Then open: + +- **https://allerion.io** — marketing site + CRM console at `/crm` +- **https://skills.allerion.io** — the store (and `…/healthz` for status) + +If certs don't issue: DNS isn't pointing here yet, or 80/443 are firewalled. + +## 5. Turn on payments (Stripe) + +1. Create a Stripe account → get your secret key. +2. **Rehearse first** with a test key: + ```bash + # in deploy/.env + STRIPE_API_KEY=sk_test_... + ``` + `docker compose up -d` again, then buy a product using Stripe's test card + `4242 4242 4242 4242` (any future expiry, any CVC). You should be redirected + back and get a real download link. +3. **Go live**: swap in `sk_live_...` and `docker compose up -d`. Real cards now + charge real money; purchases deliver the license-keyed zip instantly. +4. **Add the webhook (recommended)** so sales record durably even if the buyer + closes the success page. In the Stripe Dashboard → Developers → Webhooks → + Add endpoint, enter `https://skills.allerion.io/webhook`, subscribe to the + `checkout.session.completed` event, and copy the generated **Signing secret** + (`whsec_…`) into `STRIPE_WEBHOOK_SECRET` in `.env`, then `docker compose up -d`. + +Optional — mint stable Stripe Price ids instead of inline prices (cleaner Stripe +dashboard / analytics): +```bash +STRIPE_API_KEY=sk_live_... python3 ../apps/store/setup_stripe.py # prints export lines +# paste the printed STRIPE_PRICE_* values into deploy/.env, then: docker compose up -d +``` + +## 6. If you already run the AI gateway (`infra/ai-gateway`) + +That stack ships its **own** Caddy, and only one process can bind ports 80/443. +Pick one: + +- **Simplest:** stop the gateway's standalone Caddy, uncomment the + `api.allerion.io` block in `deploy/Caddyfile`, put the LiteLLM container on the + same Docker network, and let this single Caddy serve all of + `allerion.io` / `skills.allerion.io` / `api.allerion.io`. +- **Or:** run the gateway on a different box / IP and keep `api.allerion.io`'s + DNS pointed there. + +## Operating it + +```bash +docker compose ps # status +docker compose logs -f store # store logs +docker compose pull && docker compose up -d --build # update after a git pull +docker compose down # stop (data persists in named volumes) +``` + +Data lives in Docker volumes (`platform_data`, `store_data`) — SQLite for CRM +leads and store orders. `make backup` tars both volumes to timestamped files; +`make help` lists every shortcut (`up`, `down`, `logs`, `pull`, `ps`, `backup`). + +## Automate updates (optional) + +`.github/workflows/deploy.yml` can redeploy on every push: it SSHes into the VPS +and runs `git pull --ff-only && docker compose up -d --build`. It stays inert +until you add these repo secrets (GitHub → Settings → Secrets and variables → +Actions): `VPS_HOST`, `VPS_USER`, `VPS_SSH_KEY` (a private key authorized on the +box), and optional `VPS_PORT`. It assumes the repo is checked out at `~/allerion` +on the VPS (step 2). Until then, just `make pull` on the box to update by hand. + +## What "live" then means + +- `https://allerion.io` and `https://skills.allerion.io` are public with HTTPS. +- With a live `STRIPE_API_KEY`, the store takes real payments and delivers + downloads automatically. +- `https://skills.allerion.io/healthz` reports `stripe_live: true` and your + order/revenue counts once sales come in. diff --git a/deploy/Makefile b/deploy/Makefile new file mode 100644 index 0000000..71b297e --- /dev/null +++ b/deploy/Makefile @@ -0,0 +1,63 @@ +# Allerion deploy convenience targets. +# +# Run these from inside deploy/ (where docker-compose.yml lives): +# +# make up # build + start the stack +# make logs # tail all logs (make logs s=store tails one service) +# make pull # git pull --ff-only, then rebuild + restart (the "update" flow) +# make backup # snapshot the SQLite volumes to timestamped tarballs +# +# Compose prefixes named volumes with the project name (the deploy/ dir), +# so store_data becomes deploy_store_data. Override with: make backup PROJECT=allerion +PROJECT ?= deploy + +# Optional single-service selector for `logs` (e.g. make logs s=store) +s ?= + +.PHONY: help up down restart logs ps pull backup + +# Default target: list what's available. +help: + @echo "Allerion deploy targets (run from deploy/):" + @echo " make up - build and start the stack (docker compose up -d --build)" + @echo " make down - stop the stack (data persists in named volumes)" + @echo " make restart - down then up" + @echo " make logs - tail all logs; 'make logs s=store' tails one service" + @echo " make ps - show container status" + @echo " make pull - git pull --ff-only then rebuild + restart (update flow)" + @echo " make backup - tar store_data + platform_data to timestamped tarballs" + @echo " make help - show this message (default)" + @echo "" + @echo " PROJECT=$(PROJECT) (compose volume prefix; override for backup if needed)" + +up: + docker compose up -d --build + +down: + docker compose down + +restart: down up + +# Tail logs. With s= tail just that one (e.g. make logs s=store). +logs: + docker compose logs -f $(s) + +ps: + docker compose ps + +# Standard update: fast-forward the checkout, then rebuild + restart. +pull: + git pull --ff-only && docker compose up -d --build + +# Back up the SQLite named volumes to timestamped tarballs in the current dir, +# using a throwaway busybox container to read each volume. Volume names are +# compose-prefixed (PROJECT_); override PROJECT if your project name differs. +backup: + @ts=$$(date +%Y%m%d-%H%M%S); \ + echo "Backing up $(PROJECT)_store_data -> store_data-$$ts.tar.gz"; \ + docker run --rm -v $(PROJECT)_store_data:/data:ro -v "$$PWD":/backup busybox \ + tar czf /backup/store_data-$$ts.tar.gz -C /data .; \ + echo "Backing up $(PROJECT)_platform_data -> platform_data-$$ts.tar.gz"; \ + docker run --rm -v $(PROJECT)_platform_data:/data:ro -v "$$PWD":/backup busybox \ + tar czf /backup/platform_data-$$ts.tar.gz -C /data .; \ + echo "Done. Tarballs written to $$PWD" diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..c8e160d --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,62 @@ +# Allerion customer-facing web — one command to go live. +# +# cd deploy && cp .env.example .env # then fill in .env +# docker compose up -d --build +# +# Caddy terminates TLS for allerion.io + skills.allerion.io and reverse-proxies +# to the two stdlib Python apps. SQLite data persists in named volumes. + +services: + caddy: + image: caddy:2 + restart: unless-stopped + ports: + - "80:80" + - "443:443" + environment: + - ACME_EMAIL=${ACME_EMAIL:-admin@allerion.io} + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + - platform + - store + + platform: + build: ../apps/platform + restart: unless-stopped + environment: + - PLATFORM_BASE_URL=${PLATFORM_BASE_URL:-https://allerion.io} + - STORE_URL=${STORE_URL:-https://skills.allerion.io} + - STRIPE_API_KEY=${STRIPE_API_KEY:-} + - STRIPE_PRICE_TEAM=${STRIPE_PRICE_TEAM:-} + - ALLERION_CRM_DB=/data/crm.db + volumes: + - platform_data:/data + + store: + build: ../apps/store + restart: unless-stopped + environment: + - STORE_BASE_URL=${STORE_BASE_URL:-https://skills.allerion.io} + - STORE_SIGNING_SECRET=${STORE_SIGNING_SECRET:?set STORE_SIGNING_SECRET in .env} + - STRIPE_API_KEY=${STRIPE_API_KEY:-} + - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-} + - STORE_DB=/data/store.db + # Optional stable Stripe Price ids (from setup_stripe.py). Leave blank to + # use inline price_data. + - STRIPE_PRICE_PR_REVIEW_PRO=${STRIPE_PRICE_PR_REVIEW_PRO:-} + - STRIPE_PRICE_CHANGELOG_CRAFT=${STRIPE_PRICE_CHANGELOG_CRAFT:-} + - STRIPE_PRICE_TEST_FORGE=${STRIPE_PRICE_TEST_FORGE:-} + - STRIPE_PRICE_CODEX_DEVKIT=${STRIPE_PRICE_CODEX_DEVKIT:-} + - STRIPE_PRICE_DEVKIT_BUNDLE=${STRIPE_PRICE_DEVKIT_BUNDLE:-} + - STRIPE_PRICE_ALLERION_ASSISTANT=${STRIPE_PRICE_ALLERION_ASSISTANT:-} + volumes: + - store_data:/data + +volumes: + caddy_data: + caddy_config: + platform_data: + store_data: diff --git a/deploy/preflight.sh b/deploy/preflight.sh new file mode 100755 index 0000000..e80c5ad --- /dev/null +++ b/deploy/preflight.sh @@ -0,0 +1,213 @@ +#!/bin/sh +# preflight.sh — Allerion deploy pre-flight checks. +# +# Run this from the deploy/ directory BEFORE `docker compose up`: +# +# cd deploy && ./preflight.sh +# +# It verifies the host is ready to bring the stack up and obtain TLS certs: +# - docker + the `docker compose` plugin are installed +# - deploy/.env exists and has a non-empty STORE_SIGNING_SECRET (HARD FAIL) +# - Stripe key sanity (warn-only: empty = preview, sk_test_ = rehearsal) +# - DNS A records for allerion.io / www.allerion.io / skills.allerion.io +# resolve, ideally to this machine's public IP (warn-only) +# - ports 80/443 are not already taken locally (warn-only) +# +# Hard failures exit non-zero. Warnings never fail the run. Missing optional +# tools (dig, ss, curl, ...) are reported as "skipped", never fatal. +# +# Exit codes: 0 = all hard checks passed; 1 = at least one hard check failed. + +set -eu + +# --- config: the public hostnames Caddy serves ------------------------------ +HOSTS="allerion.io www.allerion.io skills.allerion.io" + +# --- state ------------------------------------------------------------------ +FAILED=0 +WARNED=0 + +# Resolve the directory this script lives in (so it works from anywhere). +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ENV_FILE="$SCRIPT_DIR/.env" + +pass() { printf 'PASS %s\n' "$1"; } +fail() { printf 'FAIL %s\n' "$1"; FAILED=$((FAILED + 1)); } +warn() { printf 'WARN %s\n' "$1"; WARNED=$((WARNED + 1)); } +skip() { printf 'SKIP %s\n' "$1"; } +info() { printf ' %s\n' "$1"; } + +have() { command -v "$1" >/dev/null 2>&1; } + +echo "== Allerion deploy preflight ==" +echo + +# --- 1. docker + compose plugin -------------------------------------------- +echo "-- docker --" +if have docker; then + pass "docker is installed ($(docker --version 2>/dev/null || echo 'version unknown'))" + if docker compose version >/dev/null 2>&1; then + pass "docker compose plugin is installed ($(docker compose version --short 2>/dev/null || echo 'version unknown'))" + else + fail "the 'docker compose' plugin is not available — install it (see DEPLOY.md §0)" + fi +else + fail "docker is not installed — run: curl -fsSL https://get.docker.com | sh (DEPLOY.md §0)" +fi +echo + +# --- 2. .env + required secrets -------------------------------------------- +echo "-- .env --" +if [ -f "$ENV_FILE" ]; then + pass ".env exists at $ENV_FILE" + + # Read a KEY=VALUE from .env, stripping surrounding quotes/whitespace. + # Last assignment wins (matches shell/compose semantics). + read_env() { + # $1 = key name + grep -E "^[[:space:]]*$1=" "$ENV_FILE" 2>/dev/null \ + | tail -n 1 \ + | sed -e "s/^[[:space:]]*$1=//" \ + -e 's/^"//' -e 's/"$//' \ + -e "s/^'//" -e "s/'\$//" \ + | tr -d '\r' + } + + SIGNING_SECRET=$(read_env STORE_SIGNING_SECRET || true) + if [ -n "${SIGNING_SECRET:-}" ]; then + pass "STORE_SIGNING_SECRET is set (the store can sign licenses)" + else + fail "STORE_SIGNING_SECRET is empty/missing — the store won't sign licenses securely." + info "Generate a stable one: echo \"STORE_SIGNING_SECRET=\$(openssl rand -hex 32)\" >> .env" + fi + + STRIPE_KEY=$(read_env STRIPE_API_KEY || true) + if [ -z "${STRIPE_KEY:-}" ]; then + warn "STRIPE_API_KEY is empty — store boots in PREVIEW mode (checkout disabled)." + else + case "$STRIPE_KEY" in + sk_test_*) + warn "STRIPE_API_KEY is a TEST key (sk_test_) — rehearsal mode, not real charges." ;; + sk_live_*) + pass "STRIPE_API_KEY is a LIVE key (sk_live_) — real payments enabled." ;; + *) + warn "STRIPE_API_KEY is set but doesn't look like sk_test_/sk_live_ — double-check it." ;; + esac + fi +else + fail ".env not found at $ENV_FILE — copy it: cp .env.example .env (DEPLOY.md §3)" +fi +echo + +# --- 3. discover this machine's public IP (best effort) -------------------- +echo "-- public IP --" +MY_IP="" +if have curl; then + MY_IP=$(curl -s --max-time 5 https://api.ipify.org 2>/dev/null || true) + if [ -n "$MY_IP" ]; then + pass "this machine's public IP appears to be $MY_IP" + else + warn "couldn't determine public IP (api.ipify.org unreachable) — skipping IP match." + fi +else + skip "public IP check skipped: curl not found" +fi +echo + +# --- 4. DNS A records ------------------------------------------------------- +echo "-- DNS --" +# Pick a resolver tool once. +RESOLVER="" +if have getent; then + RESOLVER="getent" +elif have dig; then + RESOLVER="dig" +elif have nslookup; then + RESOLVER="nslookup" +fi + +if [ -z "$RESOLVER" ]; then + skip "DNS checks skipped: none of getent/dig/nslookup found" +else + for h in $HOSTS; do + IPS="" + case "$RESOLVER" in + getent) + # getent hosts may print v4 and v6; keep IPv4-looking lines. + IPS=$(getent ahostsv4 "$h" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ') + if [ -z "$IPS" ]; then + IPS=$(getent hosts "$h" 2>/dev/null | awk '{print $1}' | sort -u | tr '\n' ' ') + fi + ;; + dig) + IPS=$(dig +short A "$h" 2>/dev/null | grep -E '^[0-9]+\.' | tr '\n' ' ') + ;; + nslookup) + IPS=$(nslookup "$h" 2>/dev/null | awk '/^Address: /{print $2}' | grep -E '^[0-9]+\.' | tr '\n' ' ') + ;; + esac + IPS=$(echo "$IPS" | sed 's/[[:space:]]*$//') + + if [ -z "$IPS" ]; then + warn "$h does not resolve to an A record — Caddy can't issue TLS until DNS points here." + elif [ -n "$MY_IP" ]; then + # Does any resolved IP match this box? + MATCH=0 + for ip in $IPS; do + [ "$ip" = "$MY_IP" ] && MATCH=1 + done + if [ "$MATCH" -eq 1 ]; then + pass "$h -> $IPS (matches this machine)" + else + warn "$h -> $IPS (does NOT match this machine's $MY_IP) — TLS issuance will fail." + fi + else + # We have IPs but no public IP to compare against. + pass "$h -> $IPS" + info "(couldn't compare against this machine's IP; verify it points here)" + fi + done +fi +echo + +# --- 5. ports 80 / 443 ------------------------------------------------------ +echo "-- ports 80/443 --" +PORT_TOOL="" +if have ss; then + PORT_TOOL="ss" +elif have netstat; then + PORT_TOOL="netstat" +fi + +if [ -z "$PORT_TOOL" ]; then + skip "port checks skipped: neither ss nor netstat found" +else + if [ "$PORT_TOOL" = "ss" ]; then + LISTEN=$(ss -ltn 2>/dev/null || true) + else + LISTEN=$(netstat -ltn 2>/dev/null || true) + fi + for p in 80 443; do + # Match ":80 " or ":443 " at the local-address column end. + if printf '%s\n' "$LISTEN" | grep -Eq "[:.]$p[[:space:]]"; then + warn "port $p is already in use locally — Caddy needs it." + info "Likely an existing gateway Caddy holding 80/443. See DEPLOY.md §6 to consolidate." + else + pass "port $p is free" + fi + done +fi +echo + +# --- summary ---------------------------------------------------------------- +echo "== summary ==" +if [ "$FAILED" -gt 0 ]; then + printf 'RESULT: FAIL (%s hard failure(s), %s warning(s)). Fix the FAIL items above before deploying.\n' "$FAILED" "$WARNED" + exit 1 +fi +if [ "$WARNED" -gt 0 ]; then + printf 'RESULT: PASS with %s warning(s). Review the WARN items — DNS/ports/Stripe may need attention.\n' "$WARNED" +else + echo "RESULT: PASS — ready for: docker compose up -d --build" +fi +exit 0 diff --git a/deploy/smoke.sh b/deploy/smoke.sh new file mode 100755 index 0000000..9103aea --- /dev/null +++ b/deploy/smoke.sh @@ -0,0 +1,101 @@ +#!/bin/sh +# smoke.sh — Allerion post-deploy smoke test. +# +# Run this AFTER `docker compose up` to confirm the live sites are healthy: +# +# ./smoke.sh +# +# By default it hits the public endpoints: +# https://allerion.io/healthz (platform) +# https://skills.allerion.io/healthz (store) +# +# Optional first arg overrides the base scheme://host to test a single local +# instance instead of the two public sites, e.g.: +# +# ./smoke.sh http://127.0.0.1:8088 # one local store instance +# ./smoke.sh http://127.0.0.1:8099 # one local platform instance +# +# Each check curls /healthz and asserts the body contains "ok": true. +# For the store it also surfaces stripe_live and the products count. +# +# Exit codes: 0 = every checked site healthy; 1 = at least one unhealthy. + +set -eu + +PLATFORM_URL="https://allerion.io" +STORE_URL="https://skills.allerion.io" + +FAILED=0 + +if ! command -v curl >/dev/null 2>&1; then + echo "FAIL curl is required but not installed." + exit 1 +fi + +# Extract a JSON field value (string/bool/number) without jq. +# json_field '' '' -> prints value, or empty if absent. +json_field() { + # Tolerates `"key": value` and `"key":value`. Stops at , } or whitespace. + printf '%s' "$1" \ + | tr -d '\n' \ + | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\{0,1\}\([^\",}]*\).*/\1/p" \ + | head -n 1 +} + +# check_site