Skip to content

feat(proxy): /entitlement endpoint + tier-aware /pair-client cap (PR 2/5 — premium tier)#49

Draft
DocNR wants to merge 1 commit into
feat/entitlements-storagefrom
feat/entitlement-endpoint
Draft

feat(proxy): /entitlement endpoint + tier-aware /pair-client cap (PR 2/5 — premium tier)#49
DocNR wants to merge 1 commit into
feat/entitlements-storagefrom
feat/entitlement-endpoint

Conversation

@DocNR
Copy link
Copy Markdown
Owner

@DocNR DocNR commented May 10, 2026

Summary

PR 2 of the premium-tier series. Wires the entitlements module from PR #48 into proxy.js — the proxy is now the authoritative source for tier resolution and cap enforcement.

Stacks on #48. Base branch is feat/entitlements-storage. Will rebase if PR #48 receives review changes. Merge order: PR #48 → PR 2.

What this changes

1. entitlementsStorage initialized at boot

Alongside clientsStorage and tokensStorage. Reads from ./entitlements.json. Missing file = everyone is free → no behavior change for current users.

2. New endpoint: GET /entitlement?pubkey=<hex>

  • Auth: NIP-98 (any signer — entitlement state is roughly public; auth keeps scrapers out)
  • Response:
    {
      "pubkey": "<hex>",
      "tier": "free" | "premium",
      "max_accounts": 4 | 10,
      "max_clients": 5 | 30,
      "granted_at": 1714000000,    // present if entry exists
      "expires_at": null,           // present if entry exists
      "granted_by": "admin:..."     // present if entry has source
    }
  • Optional header X-APNs-Token-Prefix: <8 hex>: records the querying device into devices_seen for the abuse tripwire, ONLY if the queried pubkey has a non-free entitlement (free tier doesn't accumulate device data — privacy-preserving)

Wrapped in async IIFE because the outer http.createServer callback is sync; same pattern other endpoints use via req.on("end", async () => …) for POST bodies.

3. Tier-aware cap in /pair-client

  • Replaced hardcoded if (projectedCount > 5) with if (projectedCount > entitlementsStorage.maxClientsForTier(tier))
  • Default tier "free" → cap 5 (no behavior change for unprivileged users); tier "premium" → cap 30
  • Error code stays pairing_limit for iOS backwards compat
  • Response gains tier field; limit carries the dynamic cap

Verification

Unit tests

Smoke tests on proxy-test.clave.casa (refreshed to current main, then this branch deployed)

$ curl "https://proxy-test.clave.casa/entitlement?pubkey=aaaa…"
{"error":"Missing X-Clave-Auth header"}    http_status=401   ✅

$ curl -X POST "https://proxy-test.clave.casa/pair-client" -d "{}"
{"error":"Missing X-Clave-Auth header"}    http_status=401   ✅ (no regression)

$ curl "https://proxy-test.clave.casa/health"
{"ok":true,"total_tokens":5,...}            http_status=200   ✅

Service logs:

[HTTP] Listening on port 3047
[Relay] Connecting to ws://localhost:7778...
[Relay] Connected
[Relay] Watching kind:24133 events for all registered signer pubkeys
[HTTP] /entitlement 401: Missing X-Clave-Auth header   ← endpoint registered, auth firing
[HTTP] /pair-client 401: Missing X-Clave-Auth header

What's NOT verified yet (intentional — PR 4/5 covers these)

  • NIP-98 auth path with a real signed event (no shell-friendly NIP-98 signer; needs iOS or nak integration)
  • Tier-aware cap firing with an actual entitlement record in place — first end-to-end exercise lands when PR 3 (grant CLI) + PR 4/5 (iOS) both ship

The unit-test coverage of the underlying entitlements module (32 tests in PR #48) covers the read paths this endpoint exercises.

Files changed

relay-proxy/proxy.js — 99 insertions, 2 deletions.

Risk

Low. Production proxy is unaffected (this PR ships to proxy-test.clave.casa for verification; deploy to proxy.clave.casa only after PR #48 + this PR merge). Even on the test proxy, behavior is unchanged for existing users (no granted entitlements yet → everyone free → cap stays 5).

🤖 Generated with Claude Code

PR 2 of the premium-tier series. Wires the entitlements module from
PR #48 (`feat/entitlements-storage`) into proxy.js — proxy is now
the authoritative source for tier resolution and cap enforcement.

Changes:

1. Init entitlementsStorage alongside clientsStorage + tokensStorage,
   reading from `./entitlements.json` (created on first
   `setEntitlement` call; missing file = everyone is free, no behavior
   change for current users).

2. New `GET /entitlement?pubkey=<hex>` endpoint:
   - NIP-98 auth required (any signer — entitlement state is roughly
     public; auth keeps scrapers out)
   - Returns {pubkey, tier, max_accounts, max_clients, granted_at?,
     expires_at?, granted_by?}
   - Optional `X-APNs-Token-Prefix: <8 hex>` header records the
     querying device into `devices_seen` for the abuse tripwire,
     ONLY if the queried pubkey has a non-free entitlement (free
     tier doesn't accumulate device data)
   - Wrapped in async IIFE because the outer http.createServer callback
     is sync (same pattern other endpoints use for POST bodies)

3. `/pair-client` cap-check is now tier-aware:
   - Replaced hardcoded `> 5` with
     `> entitlementsStorage.maxClientsForTier(tier)`
   - Default tier "free" → cap 5 (no behavior change for unprivileged
     users); tier "premium" → cap 30
   - Error code stays `pairing_limit` for iOS backwards compat;
     response gains `tier` field + `limit` carries the dynamic cap

iOS will start consuming /entitlement in PR 4 (model + service); the
tier-aware cap takes effect server-side regardless of iOS version, but
without granted entries everyone stays at the existing 5-client cap.

Tests: full proxy suite green at 111/111. Endpoint integration tests
require an HTTP test harness which doesn't exist in this codebase
yet — verification will happen via test-proxy smoke tests + iOS
device tests in PR 4/5.

Stacks on PR #48 (entitlements storage module). Will rebase if
PR #48 receives review changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@DocNR DocNR marked this pull request as draft May 10, 2026 04:15
@DocNR DocNR added the queued-phase-2 Premium-tier work paused; revisit when payment architecture is chosen label May 10, 2026
@DocNR
Copy link
Copy Markdown
Owner Author

DocNR commented May 10, 2026

Paused — queued for Phase 2.

This is part of a server-side entitlement series (#48#49#50, plus an uncommitted iOS service layer on feat/ios-entitlement-service). Review determined the stack is premature for current needs: the immediate goal (let developer + testers exceed default 4-account / 5-client caps) can be met with iOS-only allowlist gating (~150 LOC, no server changes), now being built separately on a new branch.

Preserved as draft because:

  1. Well-tested + harmless — pure additive, no production behavior change. Full proxy suite green at 126/126.
  2. ~80% of the work for the eventual Lightning-native paid tier if that's the chosen direction.
  3. Can be closed without prejudice if the project chooses Apple IAP instead — device-token-bound IAP + "Restore Purchases" makes server-side npub-keyed state unnecessary.

Architecture decision (Apple IAP vs Lightning device-bound vs Lightning npub-bound) deferred until there's a real monetization timeline. No action needed here. Will revisit when payment work begins.

Plan: ~/.claude/plans/i-d-like-to-create-eventual-moon.md

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

queued-phase-2 Premium-tier work paused; revisit when payment architecture is chosen

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant