feat(premium): recurring tiered relay-posting subscription (premium-post)

A paid premium tier gating GENERAL Nostr posting to the operator's relay, while
wallet-functional events (encrypted DMs, tips, swap coordination) stay free for
registered users. Registration itself stays free (PoW-gated). Off by default.

- Admission: a new `premium-post` write policy gates on kind x membership —
  premium author -> any kind; registered + WALLET_KINDS (1059 gift-wrap, 10050
  DM-relay-list) -> free; registered + general -> premium required; external ->
  gift-wrap DM inbox only. nauthz resolves is_premium_npub (only under premium-post),
  fail-closed.
- Payment: reuses the PaymentProvider seam (PULL model, non-custodial — only reads
  invoice status). POST /premium/invoice {plan} mints a tier invoice bound to the
  JWT user_id; POST /premium/activate verifies Settled + consumes single-use AND
  extends premium_until in ONE transaction (a mid-flight failure keeps the paid
  invoice redeemable); GET /premium/status reads it. Per-user pending-invoice cap.
- Tiered plans (PREMIUM_PLANS id:days:amount) = a built-in discount feature;
  advertised via /capabilities (premium_relay + premium{currency,plans,relay_url}).
- Config fail-closes: PREMIUM_ENABLED needs relay on + premium-post + processor
  creds + >=1 positive-decimal plan + unique ids + days<=3660, and the 0-conf /
  expiry processor floors apply on the premium path too (its common case is
  require_payment=false).
- Tests: 15 policy unit (incl. 5 premium-post) + config fail-closed matrix + plan
  parser + L1 integration (activate single-use/stacking/cross-user, is_premium by
  expiry, feature gate). OpenAPI + migration updated.

Adversarially reviewed (correctness / security-payment / operational-config); the
0-conf-on-premium-path double-spend footgun, non-transactional activate, and
unbounded plan days were caught and fixed before commit.
This commit is contained in:
jwinterm
2026-07-02 17:56:44 -04:00
parent b4935b15e8
commit eb98e4d625
15 changed files with 1339 additions and 27 deletions
+248
View File
@@ -513,6 +513,119 @@
]
}
},
"/premium/activate": {
"post": {
"tags": [
"premium"
],
"summary": "Verify a settled premium invoice and extend the caller's premium window.",
"operationId": "activate",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ActivateReq"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Premium extended",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StatusResp"
}
}
}
},
"400": {
"description": "Unknown, unpaid, or already-used invoice"
},
"401": {
"description": "Missing or invalid token"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/premium/invoice": {
"post": {
"tags": [
"premium"
],
"summary": "Mint a premium invoice for a chosen plan, bound to the caller.",
"operationId": "invoice",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvoiceReq"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Invoice minted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/InvoiceResp"
}
}
}
},
"400": {
"description": "Premium off or unknown plan"
},
"401": {
"description": "Missing or invalid token"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/premium/status": {
"get": {
"tags": [
"premium"
],
"summary": "The caller's current premium status.",
"operationId": "status",
"responses": {
"200": {
"description": "Premium status",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/StatusResp"
}
}
}
},
"401": {
"description": "Missing or invalid token"
}
},
"security": [
{
"bearer_auth": []
}
]
}
},
"/prices": {
"get": {
"tags": [
@@ -1770,6 +1883,17 @@
},
"components": {
"schemas": {
"ActivateReq": {
"type": "object",
"required": [
"invoice_id"
],
"properties": {
"invoice_id": {
"type": "string"
}
}
},
"AddressRequest": {
"type": "object",
"description": "An address query for a UTXO asset.",
@@ -1987,6 +2111,17 @@
}
]
},
"premium": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/PremiumCapability",
"description": "Premium tier details; present only when `features.premium_relay`."
}
]
},
"registration": {
"$ref": "#/components/schemas/RegistrationCapability",
"description": "Registration gates for a new wallet on this instance."
@@ -2265,6 +2400,7 @@
"prices",
"nostr_identity",
"nostr_relay",
"premium_relay",
"tips"
],
"properties": {
@@ -2280,6 +2416,10 @@
"type": "boolean",
"description": "First-party Nostr relay (encrypted DM inbox). See `messaging` for details."
},
"premium_relay": {
"type": "boolean",
"description": "Paid premium tier for general Nostr posting to the relay. See `premium`."
},
"prices": {
"type": "boolean",
"description": "Fiat price feed."
@@ -2603,6 +2743,46 @@
}
}
},
"InvoiceReq": {
"type": "object",
"required": [
"plan"
],
"properties": {
"plan": {
"type": "string",
"description": "Plan id (see `/capabilities` → `premium.plans`), e.g. `quarter`."
}
}
},
"InvoiceResp": {
"type": "object",
"required": [
"invoice_id",
"pay_to",
"plan",
"amount",
"currency"
],
"properties": {
"amount": {
"type": "string"
},
"currency": {
"type": "string"
},
"invoice_id": {
"type": "string"
},
"pay_to": {
"type": "string",
"description": "Where to pay (a checkout URL or address)."
},
"plan": {
"type": "string"
}
}
},
"LogoutRequest": {
"type": "object",
"required": [
@@ -2912,6 +3092,51 @@
}
}
},
"PremiumCapability": {
"type": "object",
"description": "Premium tier details (present only when `features.premium_relay`). The wallet\nrenders the plan tiers (discount visible) and gates general-Nostr posting to\nthe Smirk relay on the user's premium status; wallet events stay free.",
"required": [
"currency",
"plans",
"relay_url"
],
"properties": {
"currency": {
"type": "string"
},
"plans": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PremiumPlanInfo"
}
},
"relay_url": {
"type": "string",
"description": "The relay premium posting targets (mirrors `messaging.relay_url`)."
}
}
},
"PremiumPlanInfo": {
"type": "object",
"description": "A single premium plan (discount tier): `days` of relay-posting access for\n`amount` in [`PremiumCapability::currency`].",
"required": [
"id",
"days",
"amount"
],
"properties": {
"amount": {
"type": "string"
},
"days": {
"type": "integer",
"format": "int32"
},
"id": {
"type": "string"
}
}
},
"PricesResponse": {
"type": "object",
"required": [
@@ -3347,6 +3572,25 @@
}
}
},
"StatusResp": {
"type": "object",
"required": [
"active"
],
"properties": {
"active": {
"type": "boolean",
"description": "Whether the caller currently holds active premium."
},
"premium_until": {
"type": [
"string",
"null"
],
"description": "Premium expiry (RFC3339), or null if never premium / lapsed."
}
}
},
"SubmitRequest": {
"type": "object",
"description": "Submit a finalized, signed transaction.",
@@ -3799,6 +4043,10 @@
{
"name": "grin_relay",
"description": "Grin slatepack relay: async non-custodial mailbox for interactive Grin transfers (feature-flagged)."
},
{
"name": "premium",
"description": "Premium subscription: mint a tier invoice, activate on settlement, read status (feature-flagged)."
}
]
}