From b021c85fdad5bf0037e175dcc418d11e977f103b Mon Sep 17 00:00:00 2001 From: DocNR Date: Sat, 9 May 2026 20:59:23 -0400 Subject: [PATCH] feat(proxy): add /entitlement endpoint + tier-aware /pair-client cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=` 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) --- relay-proxy/proxy.js | 101 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/relay-proxy/proxy.js b/relay-proxy/proxy.js index 9a055ac..dc6c2d7 100644 --- a/relay-proxy/proxy.js +++ b/relay-proxy/proxy.js @@ -27,6 +27,7 @@ const PONG_TIMEOUT = 10000; // 10 seconds to respond const { parseAuthHeader, verifyNip98, sha256Hex } = require("./nip98"); const { createStorage } = require("./storage"); const { createClientsStorage } = require("./clients"); +const { createEntitlementsStorage } = require("./entitlements"); const { createRelayPool } = require("./relayPool"); const { createApnsClient, shouldPruneToken, parseReason } = require("./apnsClient"); @@ -36,6 +37,8 @@ const PUBLIC_PROXY_URL = process.env.PUBLIC_PROXY_URL || "https://proxy.clave.ca const storage = createStorage(TOKEN_FILE); const CLIENTS_FILE = "./clients.json"; const clientsStorage = createClientsStorage(CLIENTS_FILE); +const ENTITLEMENTS_FILE = "./entitlements.json"; +const entitlementsStorage = createEntitlementsStorage(ENTITLEMENTS_FILE); let relayPool = null; // initialized in server.listen after all deps are in scope const migrationResult = storage.migrateIfLegacy(); if (migrationResult.migrated) { @@ -350,9 +353,15 @@ const server = http.createServer((req, res) => { const alreadyPaired = clientsStorage.loadAll() .some((p) => p.signerPubkey === signerPubkey && p.clientPubkey === client_pubkey); const projectedCount = alreadyPaired ? existingCount : existingCount + 1; - if (projectedCount > 5) { + // Tier-aware cap: free → 5, premium → 30. Default is "free" for any + // signer without an entitlement record. Error code stays "pairing_limit" + // for backwards compat with iOS handlers; new fields `tier` + dynamic + // `limit` carry the additional info iOS PR 5 will read. + const tier = entitlementsStorage.tierForPubkey(signerPubkey); + const cap = entitlementsStorage.maxClientsForTier(tier); + if (projectedCount > cap) { res.writeHead(409, { "Content-Type": "application/json" }); - return res.end(JSON.stringify({ error: "pairing_limit", limit: 5, used: existingCount })); + return res.end(JSON.stringify({ error: "pairing_limit", limit: cap, used: existingCount, tier })); } const novel = clientsStorage.novelRelayCount(signerPubkey, relay_urls); @@ -448,6 +457,94 @@ const server = http.createServer((req, res) => { res.end(JSON.stringify({ error: "Internal error" })); } }); + } else if (req.method === "GET" && req.url && req.url.startsWith("/entitlement")) { + // GET /entitlement?pubkey= + // Auth: NIP-98 (any signer — entitlement state is roughly public; we just + // want the caller to be a real Clave client, not a scraper). + // Optional header X-APNs-Token-Prefix (8 hex chars): if present AND the + // queried pubkey has a non-free entitlement, we record this device + // in `devices_seen` for the abuse-tripwire audit. Used by iOS at + // launch + on add-account. + // + // Wrapped in async IIFE because the outer http.createServer callback is + // sync; same pattern other endpoints use via `req.on("end", async () => …)` + // for their POST bodies. GET has no body to await, so we await directly. + (async () => { + try { + const authHeader = req.headers["x-clave-auth"]; + if (!authHeader) { + console.log(`[HTTP] /entitlement 401: Missing X-Clave-Auth header`); + res.writeHead(401, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ error: "Missing X-Clave-Auth header" })); + } + let authEvent; + try { + authEvent = parseAuthHeader(authHeader); + } catch (e) { + console.log(`[HTTP] /entitlement 401: ${e.message}`); + res.writeHead(401, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ error: e.message })); + } + // For GET, NIP-98 spec says the URL is the full request URL including + // query string; bodyHash is empty (no body). + const fullUrl = `${PUBLIC_PROXY_URL}${req.url}`; + const result = await verifyNip98(authEvent, fullUrl, "GET", null); + if (!result.valid) { + console.log(`[HTTP] /entitlement auth failed: ${result.error}`); + res.writeHead(401, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ error: result.error })); + } + + let queriedPubkey; + try { + const u = new URL(req.url, PUBLIC_PROXY_URL); + queriedPubkey = u.searchParams.get("pubkey"); + } catch { + res.writeHead(400, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ error: "invalid_url" })); + } + if (typeof queriedPubkey !== "string" || !/^[0-9a-f]{64}$/.test(queriedPubkey)) { + res.writeHead(400, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ error: "invalid_pubkey" })); + } + + const entry = entitlementsStorage.getByPubkey(queriedPubkey); + const tier = entitlementsStorage.tierForPubkey(queriedPubkey); + + // Device-tripwire side effect: record this device's APNs token prefix + // against the queried pubkey, but only if the entry exists AND is + // currently non-free. We don't auto-create entries from queries — + // free-tier pubkeys shouldn't accumulate device-tracking data. + const tokenPrefix = req.headers["x-apns-token-prefix"]; + if (entry && tier !== "free" && typeof tokenPrefix === "string" && /^[0-9a-f]{8}$/.test(tokenPrefix)) { + entitlementsStorage.recordDevice(queriedPubkey, tokenPrefix); + } + + const response = { + pubkey: queriedPubkey, + tier, + max_accounts: entitlementsStorage.maxAccountsForTier(tier), + max_clients: entitlementsStorage.maxClientsForTier(tier), + }; + if (entry) { + response.granted_at = entry.granted_at; + response.expires_at = entry.expires_at; + if (entry.granted_by) response.granted_by = entry.granted_by; + } + + console.log( + `[HTTP] /entitlement pubkey=${queriedPubkey.slice(0, 8)}... tier=${tier}` + + (tokenPrefix ? ` device=${tokenPrefix}` : "") + ); + + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify(response)); + } catch (e) { + console.error("[HTTP] /entitlement error:", e.message); + res.writeHead(500, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ error: "Internal error" })); + } + })(); } else if (req.method === "GET" && req.url === "/health") { const allTokens = storage.loadTokens(); const pubkeys = new Set(allTokens.map((t) => t.pubkey));