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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@

### Added

- **`scripts/status.sh` tier line now surfaces Pro license expiry date.**
The status output's `tier` line parses the JWT `exp` claim from the
configured Pro license token and renders one of three shapes: `Pro
(expires YYYY-MM-DD, N days remaining)` when active, `Free (Pro
expired YYYY-MM-DD — visit https://getaxonflow.com/pro to renew)`
when the token is on disk but its `exp` has passed (plugin will not
forward an expired token), or `Free (no Pro license configured)`
when no token is loaded. Lets users see their renewal date without
hitting the agent and catches the lapsed-token state before their
next governed call. Display only — JWT signature validation remains
the platform's job.
- **Status surface (`scripts/status.sh` + `/axonflow-status` skill).** Prints
the `tenant_id` (which Pro buyers paste into the custom field at Stripe
Checkout), the active tier (`Free` or `Pro`), the agent endpoint, the
Expand Down
50 changes: 50 additions & 0 deletions runtime-e2e/tier-expiry/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# runtime-e2e/tier-expiry — V1 SaaS Plugin Pro tier-line expiry surface

## What it asserts

The user-facing `scripts/status.sh` (the same script the
`/axonflow-status` skill guides the agent to run from the integrated
terminal) renders one of three tier-line shapes depending on the
configured Pro license token's JWT `exp` claim:

| State | Shape |
|----------------|-----------------------------------------------------------------------------------|
| Free | `tier Free (no Pro license configured)` |
| Pro active | `tier Pro (expires YYYY-MM-DD, N days remaining)` |
| Pro expired | `tier Free (Pro expired YYYY-MM-DD — visit https://getaxonflow.com/pro to renew)` |

Plus security guarantees:
- Full bearer token is NEVER printed; only the last 4 chars in the
`AXON-...XXXX` redaction.
- Pro-expired output surfaces the renewal hint
(`After buying a renewal, replace the token: ...`).

## How to run

```bash
bash runtime-e2e/tier-expiry/test.sh
```

No env vars required. Mints three structurally-valid AXON- JWT tokens
on the fly with explicit `exp` claims and runs the actual
`scripts/status.sh` against them in an isolated `$HOME` so the
developer's real `~/.config/axonflow` is never touched.

## Why this is real-surface runtime proof (HARD RULE #0)

- The script under test IS the script users invoke via the integrated
terminal — same file, same path, same env-var resolution order.
- The license tokens are real AXON-prefixed JWTs (header.payload.sig
base64url-encoded). The JWT-parsing branch in `status.sh` does NOT
distinguish a platform-minted token from a test-minted one
structurally — both decode the same way and exit the same code path.
- No network mock, no fake stdout capture, no shimmed command. We
invoke the actual `scripts/status.sh` against an isolated `$HOME`
and assert the actual stdout grep-shape.

## Coverage scope

- Free path (no token) — Test 1
- Pro active (exp in future) — Test 2 + token-leak guard + last-4 redaction
- Pro expired (exp in past) — Test 3 + token-leak guard + renewal hint
- Wire-up (X-License-Token sent) — `runtime-e2e/mcp-session-headers/` (separate concern)
147 changes: 147 additions & 0 deletions runtime-e2e/tier-expiry/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# Cursor runtime E2E: V1 SaaS Plugin Pro tier-line expiry surface.
#
# Drives the user-facing surface (`scripts/status.sh`, invoked by the
# /axonflow-status skill via the integrated terminal) against three
# realistic license-token states and asserts the actual stdout matches
# the documented tier-line shapes:
#
# - Free → "tier Free (no Pro license configured)"
# - Pro active → "tier Pro (expires YYYY-MM-DD, N days remaining)"
# - Pro expired → "tier Free (Pro expired YYYY-MM-DD — visit ... to renew)"
#
# Why this is real-surface runtime proof (HARD RULE #0):
# - The script under test IS the script invoked from the user's
# integrated terminal (or via the /axonflow-status skill that
# guides the agent to bash it) — same file, same path, same
# env-var resolution order.
# - The license tokens are real AXON-prefixed JWTs (header.payload.
# sig base64url-encoded per RFC 7519). The JWT-parsing branch in
# status.sh does NOT distinguish a platform-minted token from a
# test-minted one structurally — both decode the same way and
# exit the same code path. The platform's signature validation is
# a separate concern that lives in PluginClaimMiddleware on the
# agent and is exercised by the wire-level mcp-session-headers
# runtime-e2e test.
# - No network mock, no fake stdout capture, no shimmed command. We
# invoke the actual scripts/status.sh against an isolated $HOME
# and assert the actual stdout grep-shape.

set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PLUGIN_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
STATUS_SH="${PLUGIN_DIR}/scripts/status.sh"

if [ ! -f "$STATUS_SH" ]; then
echo "FAIL: required file missing: $STATUS_SH"
exit 1
fi

if ! command -v base64 >/dev/null 2>&1; then
echo "SKIP: base64 not on PATH"
exit 0
fi

PASS=0
FAIL=0
pass() { printf ' PASS: %s\n' "$1"; PASS=$((PASS+1)); }
fail() { printf ' FAIL: %s\n' "$1"; FAIL=$((FAIL+1)); }

# Mint a structurally-valid AXON- token whose JWT payload contains a
# given `exp` (unix epoch seconds). Signature segment is a fixed
# placeholder string padded to a realistic length; status.sh extracts
# `exp` only, does NOT validate the signature.
mint_axon_jwt() {
local exp_epoch="$1"
local hdr
hdr=$(printf '%s' '{"alg":"EdDSA","typ":"JWT"}' | base64 | tr '+/' '-_' | tr -d '=')
local payload
payload=$(printf '{"sub":"runtime-e2e","exp":%s}' "$exp_epoch" | base64 | tr '+/' '-_' | tr -d '=')
local sig="placeholder-signature-padding-padding-padding-padding-padding-pa"
printf 'AXON-%s.%s.%s' "$hdr" "$payload" "$sig"
}

TMP_HOME=$(mktemp -d)
trap 'rm -rf "$TMP_HOME"' EXIT

echo "=== runtime-e2e: V1 SaaS Plugin Pro tier-expiry surface ==="
echo "Plugin dir: $PLUGIN_DIR"
echo "Script: $STATUS_SH"
echo ""

# Test 1: Free tier (no token).
echo "Test 1: Free tier — no Pro license configured"
FREE_OUT=$(AXONFLOW_TELEMETRY=off \
HOME="$TMP_HOME" \
AXONFLOW_CONFIG_DIR="$TMP_HOME/empty" \
bash "$STATUS_SH" 2>&1 || true)
if echo "$FREE_OUT" | grep -qE "tier[[:space:]]+Free \(no Pro license configured\)"; then
pass "Free tier-line shape (no Pro license configured)"
else
fail "Free tier-line missing expected shape; got:"
echo "$FREE_OUT" | sed 's/^/ /'
fi

# Test 2: Pro active.
echo ""
echo "Test 2: Pro tier active — exp in the future"
PRO_EXP=$(( $(date -u +%s) + 30 * 86400 ))
PRO_TOKEN=$(mint_axon_jwt "$PRO_EXP")
PRO_OUT=$(AXONFLOW_LICENSE_TOKEN="$PRO_TOKEN" \
AXONFLOW_TELEMETRY=off \
HOME="$TMP_HOME" \
AXONFLOW_CONFIG_DIR="$TMP_HOME/empty" \
bash "$STATUS_SH" 2>&1 || true)
if echo "$PRO_OUT" | grep -qE "tier[[:space:]]+Pro \(expires [0-9]{4}-[0-9]{2}-[0-9]{2}, [0-9]+ days remaining\)"; then
pass "Pro-active tier-line shape (expires YYYY-MM-DD, N days remaining)"
else
fail "Pro-active tier-line missing expected shape; got:"
echo "$PRO_OUT" | sed 's/^/ /'
fi
if echo "$PRO_OUT" | grep -qF "$PRO_TOKEN"; then
fail "Pro-active output leaked full token"
else
pass "Pro-active output redacts full token"
fi
PRO_TAIL4="${PRO_TOKEN: -4}"
if echo "$PRO_OUT" | grep -qF "AXON-...${PRO_TAIL4}"; then
pass "Pro-active output shows last-4 redacted preview (AXON-...${PRO_TAIL4})"
else
fail "Pro-active output missing last-4 preview"
fi

# Test 3: Pro expired.
echo ""
echo "Test 3: Pro tier expired — exp in the past"
EXPIRED_EXP=$(( $(date -u +%s) - 365 * 86400 ))
EXPIRED_TOKEN=$(mint_axon_jwt "$EXPIRED_EXP")
EXPIRED_OUT=$(AXONFLOW_LICENSE_TOKEN="$EXPIRED_TOKEN" \
AXONFLOW_TELEMETRY=off \
HOME="$TMP_HOME" \
AXONFLOW_CONFIG_DIR="$TMP_HOME/empty" \
bash "$STATUS_SH" 2>&1 || true)
if echo "$EXPIRED_OUT" | grep -qE "tier[[:space:]]+Free \(Pro expired [0-9]{4}-[0-9]{2}-[0-9]{2} — visit https?://[^ ]+ to renew\)"; then
pass "Pro-expired tier-line shape (Pro expired YYYY-MM-DD — visit ... to renew)"
else
fail "Pro-expired tier-line missing expected shape; got:"
echo "$EXPIRED_OUT" | sed 's/^/ /'
fi
if echo "$EXPIRED_OUT" | grep -qF "$EXPIRED_TOKEN"; then
fail "Pro-expired output leaked full token"
else
pass "Pro-expired output redacts full token"
fi
if echo "$EXPIRED_OUT" | grep -q "After buying a renewal, replace the token"; then
pass "Pro-expired output surfaces the renewal hint"
else
fail "Pro-expired output missing renewal hint"
fi

echo ""
echo "Summary: $PASS PASS, $FAIL FAIL"
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
echo "PASS: V1 SaaS Plugin Pro tier-expiry surface verified end-to-end"
exit 0
119 changes: 111 additions & 8 deletions scripts/status.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# Plugin status — surfaces tenant_id + tier so Pro buyers can paste the
# tenant_id into Stripe Checkout's custom field at /pro and so any user
# can quickly verify which tier they're on.
# Plugin status — surfaces tenant_id + tier (with Pro expiry date) so Pro
# buyers can paste the tenant_id into Stripe Checkout's custom field at
# /pro and so any user can quickly verify which tier they're on AND when
# their Pro license expires.
#
# Cursor does not run plugin code in a long-lived process, and the IDE has
# no command palette for plugin output. The natural surface is a script the
Expand All @@ -16,6 +17,14 @@
# value. Show "AXON-...XXXX" using only the last 4 chars (defensively
# padded). Mirrors the fix in axonflow-codex-plugin#41 (cmd_status was
# leaking the full token to the terminal).
#
# JWT exp parsing: the AXON- prefix wraps a standard JWT (header.payload.
# signature). We extract the `exp` claim from the payload to compute the
# tier-line shape:
# - Pro active → "tier Pro (expires YYYY-MM-DD, N days remaining)"
# - Pro lapsed → "tier Free (Pro expired YYYY-MM-DD — visit <url> to renew)"
# - Free → "tier Free (no Pro license configured)"
# Signature is NOT validated here — that's the platform's job.

set -uo pipefail

Expand Down Expand Up @@ -88,17 +97,95 @@ elif [ -f "$LICENSE_TOKEN_FILE" ]; then
fi
fi

# extract_jwt_exp <token> → prints unix-epoch integer to stdout, exits 0
# on success, non-zero on any parse failure. Pure stdout/stderr; never
# raises. The caller decides how to render a parse failure.
#
# AxonFlow license tokens are formatted `AXON-<JWT>` where <JWT> is a
# standard `header.payload.signature` triple. We base64url-decode the
# middle segment, then look for `"exp":<digits>`. Signature is NEVER
# validated here — display only.
extract_jwt_exp() {
local tok="$1"
[ -n "$tok" ] || return 1
local jwt="${tok#AXON-}"
local payload
payload=$(printf '%s' "$jwt" | cut -d. -f2)
[ -n "$payload" ] || return 1
payload=$(printf '%s' "$payload" | tr '_-' '/+')
local pad=$(( 4 - ${#payload} % 4 ))
if [ "$pad" -ne 4 ]; then
payload="${payload}$(printf '=%.0s' $(seq 1 "$pad"))"
fi
local decoded
decoded=$(printf '%s' "$payload" | base64 -d 2>/dev/null) \
|| decoded=$(printf '%s' "$payload" | base64 -D 2>/dev/null) \
|| return 1
[ -n "$decoded" ] || return 1
local exp
exp=$(printf '%s' "$decoded" | grep -oE '"exp"[[:space:]]*:[[:space:]]*[0-9]+' | head -1 | grep -oE '[0-9]+$')
[ -n "$exp" ] || return 1
printf '%s' "$exp"
}

# format_unix_to_date <unix-epoch> → prints YYYY-MM-DD (UTC) to stdout.
format_unix_to_date() {
local epoch="$1"
[ -n "$epoch" ] || return 1
local out
out=$(date -u -d "@${epoch}" +%Y-%m-%d 2>/dev/null) \
|| out=$(date -u -r "${epoch}" +%Y-%m-%d 2>/dev/null) \
|| return 1
printf '%s' "$out"
}

# Compute the tier line. Three branches (matching codex / claude / openclaw):
# 1. No token resolved → "Free (no Pro license configured)"
# 2. Token resolved + exp parsed:
# 2a. exp in future → "Pro (expires YYYY-MM-DD, N days remaining)"
# 2b. exp in past → "Free (Pro expired YYYY-MM-DD — visit <url> to renew)"
# 3. Token resolved + exp NOT parseable
# → "Pro (expires UNKNOWN — could not parse token)"
TIER_LINE="Free (no Pro license configured)"
PRO_EXPIRED_FLAG=0
TIER_KIND="free"

if [ -n "$LICENSE_TOKEN" ] && [ "$TOKEN_SOURCE" != "unsafe-perms" ]; then
TIER="Pro"
# Defensively pad short tokens so we never reveal the middle bytes — if
# someone test-injected a 3-char token we still show "****" not "AXO".
TAIL4="****"
if [ "${#LICENSE_TOKEN}" -ge 4 ]; then
TAIL4="${LICENSE_TOKEN: -4}"
fi
TOKEN_DISPLAY="set (AXON-...${TAIL4}, source=${TOKEN_SOURCE})"

EXP_EPOCH=$(extract_jwt_exp "$LICENSE_TOKEN" 2>/dev/null || true)
if [ -n "$EXP_EPOCH" ]; then
EXP_DATE=$(format_unix_to_date "$EXP_EPOCH" 2>/dev/null || true)
if [ -n "$EXP_DATE" ]; then
NOW_EPOCH=$(date -u +%s)
if [ "$EXP_EPOCH" -gt "$NOW_EPOCH" ]; then
SECS_LEFT=$(( EXP_EPOCH - NOW_EPOCH ))
DAYS_LEFT=$(( (SECS_LEFT + 86399) / 86400 ))
TIER_LINE="Pro (expires ${EXP_DATE}, ${DAYS_LEFT} days remaining)"
TIER_KIND="pro"
else
TIER_LINE="Free (Pro expired ${EXP_DATE} — visit ${UPGRADE_URL} to renew)"
TIER_KIND="pro-expired"
PRO_EXPIRED_FLAG=1
fi
else
TIER_LINE="Pro (expires UNKNOWN — could not parse token)"
TIER_KIND="pro"
fi
else
# Token shape valid but JWT parse failed — treat as Pro for display.
# The platform is the source of truth; if the token is junk the next
# governed call will surface the 401.
TIER_LINE="Pro (expires UNKNOWN — could not parse token)"
TIER_KIND="pro"
fi
else
TIER="Free"
if [ "$TOKEN_SOURCE" = "unsafe-perms" ]; then
TOKEN_DISPLAY="present at $LICENSE_TOKEN_FILE but refused (unsafe permissions; chmod 0600)"
else
Expand All @@ -114,15 +201,22 @@ AxonFlow Cursor plugin — status
tenant_id: ${TENANT_ID}
registration file ${REGISTRATION_FILE}
license token ${TOKEN_DISPLAY}
tier ${TIER}
upgrade ${UPGRADE_URL}
tier ${TIER_LINE}
EOF

# Only show the upgrade URL on Free tiers (active Pro users don't need it
# in their face). Pro-expired users do want to see it — it's now embedded
# in the tier line itself ("visit <url> to renew") so we suppress the
# duplicate `upgrade` line for them too.
if [ "$TIER_KIND" = "free" ]; then
printf ' upgrade %s\n' "${UPGRADE_URL}"
fi

if [ -n "$TENANT_HINT" ]; then
printf '\n hint: %s\n' "$TENANT_HINT"
fi

if [ "$TIER" = "Free" ]; then
if [ "$TIER_KIND" = "free" ]; then
cat <<EOF

To upgrade to Pro, copy your tenant_id above, visit
Expand All @@ -136,6 +230,15 @@ with one of:
Token resolution order: AXONFLOW_LICENSE_TOKEN env var, then
${LICENSE_TOKEN_FILE} (mode 0600 only).
EOF
elif [ "$PRO_EXPIRED_FLAG" -eq 1 ]; then
cat <<EOF

Your Pro license token is on disk but its 'exp' has passed; the plugin will
not forward an expired token. After buying a renewal, replace the token:

export AXONFLOW_LICENSE_TOKEN=AXON-... # current shell
printf '%s' AXON-... > ${LICENSE_TOKEN_FILE} && chmod 0600 ${LICENSE_TOKEN_FILE}
EOF
fi

exit 0
Loading
Loading