Skip to content

feat(proxy): entitlements storage factory module (PR 1/5 — premium tier)#48

Draft
DocNR wants to merge 1 commit into
mainfrom
feat/entitlements-storage
Draft

feat(proxy): entitlements storage factory module (PR 1/5 — premium tier)#48
DocNR wants to merge 1 commit into
mainfrom
feat/entitlements-storage

Conversation

@DocNR
Copy link
Copy Markdown
Owner

@DocNR DocNR commented May 10, 2026

Summary

First PR in the premium-tier entitlement series. Introduces the data layer (relay-proxy/entitlements.js + tests). Pure addition — proxy.js does not import this yet, so production behavior is unchanged.

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

  1. PR 1 (this PR): entitlements storage module + tests.
  2. PR 2: wire GET /entitlement endpoint + tier-aware /pair-client cap.
  3. PR 3: tools/grant.js admin CLI.
  4. PR 4: iOS Entitlement model + EntitlementService + tests.
  5. PR 5: iOS tier-aware caps + three-layer gate updates + Settings tier row.

What this adds

relay-proxy/entitlements.js mirrors the clients.js factory pattern with one key shape difference: storage is keyed by hex pubkey (object) rather than an array of pairs.

Schema per entry:

{
  "<pubkey_hex>": {
    "tier": "premium",
    "granted_at": 1714000000,
    "granted_by": "admin:<admin_npub_hex>",
    "expires_at": null,
    "note": "tester @bfgreen",
    "devices_seen": [
      { "token_prefix": "abc12345", "first_seen_at": ..., "last_seen_at": ... }
    ]
  }
}

Methods:

  • loadAll, getByPubkey, setEntitlement (upsert, preserves granted_at + devices_seen), revoke
  • tierForPubkey — defaults unknown to "free"; downgrades expired premium to "free" so Phase 2 time-bounded grants are forward-compatible
  • recordDevice — abuse-tripwire device tracking, dedupes by token_prefix, no-ops for unknown pubkeys (free-tier doesn't accumulate device data)
  • auditMultiDevice — flags pubkeys exceeding N distinct devices in a window
  • listByTier — for the grant CLI's list subcommand
  • maxAccountsForTier / maxClientsForTier — locked caps:
    • free: 4 accounts / 5 clients per signer
    • premium: 10 accounts / 30 clients per signer
    • Matches shipped baseline → premium is purely additive (no migration risk for existing users).

Design notes

  • Atomic writes via temp+rename, same as clients.js and tokens.json.
  • Defensive load tolerates missing / malformed / schema-drifted files (returns {}).
  • Pubkey keys validated against /^[0-9a-f]{64}$/ — invalid keys filtered on load (won't crash on legacy data).
  • recordDevice deliberately won't auto-create entries for unknown pubkeys. Free-tier shouldn't accumulate device data, and entitlement creation is grant-driven only (CLI in PR 3, Lightning later).
  • Sprint 5a prerequisite cleared (PR #41). Counters now correct across both bunker and nostrconnect flows; tier-aware multipliers in PR 2 will inherit clean counts.

Test plan

  • 32 new unit tests (relay-proxy/test/entitlements.test.js)
  • Full proxy test suite green: 111/111 (79 existing + 32 new)
  • No proxy.js changes — zero production behavior change
  • PR 2 will exercise the module via the new /entitlement endpoint + /pair-client cap-check integration

Test coverage

  • loadAll: missing file, malformed JSON, top-level array (schema drift), non-hex keys filtered, missing-required-fields entries filtered
  • setEntitlement: create + upsert (preserves granted_at, preserves devices_seen, keeps prior granted_by/note when undefined supplied), throws on invalid pubkey hex / tier / expires_at, accepts time-bounded expires_at: number
  • revoke: removes + returns; null for unknown
  • tierForPubkey: defaults unknown to free, returns premium for active, downgrades expired premium to free, honors future expiry
  • recordDevice: no-ops for unknown pubkey + empty/non-string token, dedupes by token_prefix while updating last_seen_at, keeps multiple distinct devices
  • auditMultiDevice: flags > threshold within window, excludes outside-window, returns empty when nothing flagged
  • listByTier: filters by tier, treats expired premium as free
  • max{Accounts,Clients}ForTier: 4/5 free, 10/30 premium
  • Atomic write: tmp file cleaned up after rename

Risk

Low. Pure additive module, not yet imported. PR 2 is the first PR with observable production behavior change.

🤖 Generated with Claude Code

Introduces the data layer for the premium-tier entitlement system
(plan: ~/.claude/plans/i-d-like-to-create-eventual-moon.md). Pure
addition — proxy.js does not import this yet, so behavior is
unchanged in production.

`relay-proxy/entitlements.js` mirrors the `clients.js` factory
pattern with one key shape difference: storage is keyed by hex
pubkey (object) rather than an array of pairs.

Schema per entry:
  { tier: "free"|"premium", granted_at, granted_by?, expires_at,
    note?, devices_seen[] }

Methods:
- loadAll, getByPubkey, setEntitlement (upsert), revoke
- tierForPubkey — defaults unknown to "free"; downgrades expired
  premium to "free" so Phase 2 time-bounded grants are forward-compat
- recordDevice — abuse-tripwire device tracking, dedupes by
  token_prefix, no-ops for unknown pubkeys (free-tier doesn't
  accumulate device data)
- auditMultiDevice — flags pubkeys exceeding N devices in a window
- listByTier — for the grant CLI's `list` subcommand
- maxAccountsForTier / maxClientsForTier — locked caps:
  free 4 accts / 5 clients → premium 10 / 30. Matches shipped
  baseline so premium is purely additive (no migration risk).

Atomic temp+rename writes; defensive load tolerates missing /
malformed / schema-drifted files. Pubkey keys validated against
`/^[0-9a-f]{64}$/`.

Tests: 32 new (`relay-proxy/test/entitlements.test.js`), full
proxy suite green at 111/111 (79 existing + 32 new).

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