mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
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:
+248
@@ -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)."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user