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

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
@DocNRDocNR added the queued-phase-2 Premium-tier work paused; revisit when payment architecture is chosen label May 10, 2026
@DocNR

Copy link
Copy Markdown
OwnerAuthor

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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@DocNR