Implement backend API and database services in Docker setup

- Added a new `api` service for the NestJS backend, including health checks and dependencies on PostgreSQL, Redis, and MinIO.
- Introduced PostgreSQL and Redis services with health checks and configurations for data persistence.
- Added MinIO for S3-compatible object storage and a one-shot service to initialize required buckets.
- Updated the Nginx configuration to proxy requests to the new backend API and MinIO storage.
- Enhanced the Dockerfile to support the new API environment variables and configurations.
- Updated the `package.json` and `package-lock.json` to include new dependencies for QR code generation and other utilities.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dorian
2026-02-12 20:14:39 +00:00
parent f19fd6feef
commit cdd24a5def
478 changed files with 55355 additions and 529 deletions

View File

@@ -55,9 +55,9 @@ class ApiService {
return this.client(originalRequest)
}
} catch (refreshError) {
// Token refresh failed - clear auth and redirect to login
// Token refresh failed - clear auth and redirect to home
this.clearAuth()
window.location.href = '/login'
window.location.href = '/'
return Promise.reject(refreshError)
}
}
@@ -100,6 +100,7 @@ class ApiService {
public clearAuth() {
sessionStorage.removeItem('auth_token')
sessionStorage.removeItem('nostr_token')
sessionStorage.removeItem('nostr_pubkey')
sessionStorage.removeItem('refresh_token')
}
@@ -119,13 +120,13 @@ class ApiService {
throw new Error('No refresh token available')
}
// Call refresh endpoint (implement based on backend)
const response = await axios.post(`${apiConfig.baseURL}/auth/refresh`, {
// Call Nostr refresh endpoint
const response = await axios.post(`${apiConfig.baseURL}/auth/nostr/refresh`, {
refreshToken,
})
const newToken = response.data.accessToken
this.setToken(newToken, 'cognito')
this.setToken(newToken, 'nostr')
if (response.data.refreshToken) {
sessionStorage.setItem('refresh_token', response.data.refreshToken)

View File

@@ -1,4 +1,9 @@
import axios from 'axios'
import { apiService } from './api.service'
import { nip98Service } from './nip98.service'
import { apiConfig } from '../config/api.config'
import { accountManager } from '../lib/accounts'
import type { EventTemplate } from 'nostr-tools'
import type {
LoginCredentials,
RegisterData,
@@ -8,6 +13,48 @@ import type {
ApiUser,
} from '../types/api'
/**
* Create a NIP-98 HTTP Auth event (kind 27235) and return it as
* a base64-encoded Authorization header value: `Nostr <base64>`
*/
async function createNip98AuthHeader(
url: string,
method: string,
body?: string,
): Promise<string> {
const account = accountManager.active
if (!account) throw new Error('No active Nostr account')
const tags: string[][] = [
['u', url],
['method', method.toUpperCase()],
]
// If there's a body, include its SHA-256 hash
if (body && body.length > 0) {
const encoder = new TextEncoder()
const hash = await crypto.subtle.digest('SHA-256', encoder.encode(body))
const hashHex = Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
tags.push(['payload', hashHex])
}
const template: EventTemplate = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags,
content: '',
}
// Sign with the account's signer
const signed = await account.signer.signEvent(template)
// Base64-encode the signed event JSON
const b64 = btoa(JSON.stringify(signed))
return `Nostr ${b64}`
}
/**
* Authentication Service
* Handles Cognito and Nostr authentication
@@ -68,17 +115,41 @@ class AuthService {
}
/**
* Create Nostr session
* Create Nostr session via NIP-98 HTTP Auth.
* Signs a kind-27235 event and sends it as the Authorization header.
*/
async createNostrSession(request: NostrSessionRequest): Promise<NostrSessionResponse> {
const response = await apiService.post<NostrSessionResponse>('/auth/nostr/session', request)
async createNostrSession(_request: NostrSessionRequest): Promise<NostrSessionResponse> {
const url = `${apiConfig.baseURL}/auth/nostr/session`
const method = 'POST'
// Create NIP-98 auth header — no body is sent
const authHeader = await createNip98AuthHeader(url, method)
// Send the request without a body.
// We use axios({ method }) instead of axios.post(url, data) to
// guarantee no Content-Type or body is serialized.
const response = await axios<NostrSessionResponse>({
method: 'POST',
url,
headers: { Authorization: authHeader },
timeout: apiConfig.timeout,
})
// Store Nostr token
if (response.token) {
apiService.setToken(response.token, 'nostr')
const data = response.data
// Map accessToken to token for convenience
data.token = data.accessToken
// Store Nostr JWT for subsequent authenticated requests
if (data.accessToken) {
apiService.setToken(data.accessToken, 'nostr')
sessionStorage.setItem('nostr_token', data.accessToken)
// Also populate nip98Service storage so IndeehubApiService can find the token
nip98Service.storeTokens(data.accessToken, data.refreshToken, data.expiresIn)
}
return response
return data
}
/**

View File

@@ -0,0 +1,382 @@
/**
* Filmmaker Service
*
* Source-aware API client for all filmmaker/creator endpoints.
* Routes calls to either the original API (api.service) or
* our self-hosted backend (indeehub-api.service) based on the
* active content source.
*/
import { apiService } from './api.service'
import { indeehubApiService } from './indeehub-api.service'
import { useContentSourceStore } from '../stores/contentSource'
import type {
ApiProject,
ApiContent,
ApiSeason,
ApiFilmmakerAnalytics,
ApiWatchAnalytics,
ApiPaymentMethod,
ApiPayment,
ApiGenre,
CreateProjectData,
UpdateProjectData,
UploadInitResponse,
UploadPresignedUrlsResponse,
} from '../types/api'
/**
* Check if we should route requests to our self-hosted backend
*/
function usesSelfHosted(): boolean {
const store = useContentSourceStore()
return store.activeSource === 'indeehub-api'
}
// ── Projects ────────────────────────────────────────────────────────────────────
export async function getPrivateProjects(filters?: {
type?: string
status?: string
search?: string
sort?: string
limit?: number
offset?: number
}): Promise<ApiProject[]> {
if (usesSelfHosted()) {
const params = new URLSearchParams()
if (filters?.type) params.append('type', filters.type)
if (filters?.status) params.append('status', filters.status)
if (filters?.search) params.append('search', filters.search)
if (filters?.sort) params.append('sort', filters.sort)
if (filters?.limit) params.append('limit', String(filters.limit))
if (filters?.offset) params.append('offset', String(filters.offset))
const qs = params.toString()
return indeehubApiService.get<ApiProject[]>(`/projects/private${qs ? `?${qs}` : ''}`)
}
return apiService.get<ApiProject[]>('/projects/private', { params: filters })
}
export async function getPrivateProjectsCount(filters?: {
type?: string
status?: string
search?: string
}): Promise<number> {
if (usesSelfHosted()) {
const params = new URLSearchParams()
if (filters?.type) params.append('type', filters.type)
if (filters?.status) params.append('status', filters.status)
if (filters?.search) params.append('search', filters.search)
const qs = params.toString()
const res = await indeehubApiService.get<{ count: number }>(`/projects/private/count${qs ? `?${qs}` : ''}`)
return res.count
}
const res = await apiService.get<{ count: number }>('/projects/private/count', { params: filters })
return res.count
}
export async function getPrivateProject(id: string): Promise<ApiProject> {
if (usesSelfHosted()) {
return indeehubApiService.get<ApiProject>(`/projects/private/${id}`)
}
return apiService.get<ApiProject>(`/projects/private/${id}`)
}
export async function createProject(data: CreateProjectData): Promise<ApiProject> {
if (usesSelfHosted()) {
return indeehubApiService.post<ApiProject>('/projects', data)
}
return apiService.post<ApiProject>('/projects', data)
}
export async function updateProject(id: string, data: UpdateProjectData): Promise<ApiProject> {
if (usesSelfHosted()) {
return indeehubApiService.patch<ApiProject>(`/projects/${id}`, data)
}
return apiService.patch<ApiProject>(`/projects/${id}`, data)
}
export async function deleteProject(id: string): Promise<void> {
if (usesSelfHosted()) {
await indeehubApiService.delete(`/projects/${id}`)
return
}
await apiService.delete(`/projects/${id}`)
}
export async function checkSlugExists(projectId: string, slug: string): Promise<boolean> {
if (usesSelfHosted()) {
const res = await indeehubApiService.get<{ exists: boolean }>(`/projects/${projectId}/slug/${slug}/exists`)
return res.exists
}
const res = await apiService.get<{ exists: boolean }>(`/projects/${projectId}/slug/${slug}/exists`)
return res.exists
}
// ── Content (episodes/seasons) ──────────────────────────────────────────────────
export async function getContents(projectId: string): Promise<ApiContent[]> {
if (usesSelfHosted()) {
return indeehubApiService.get<ApiContent[]>(`/contents/project/${projectId}`)
}
return apiService.get<ApiContent[]>(`/contents/project/${projectId}`)
}
export async function upsertContent(
projectId: string,
data: Partial<ApiContent>
): Promise<ApiContent> {
if (usesSelfHosted()) {
return indeehubApiService.patch<ApiContent>(`/contents/project/${projectId}`, data)
}
return apiService.patch<ApiContent>(`/contents/project/${projectId}`, data)
}
export async function getSeasons(projectId: string): Promise<ApiSeason[]> {
if (usesSelfHosted()) {
return indeehubApiService.get<ApiSeason[]>(`/seasons/project/${projectId}`)
}
return apiService.get<ApiSeason[]>(`/seasons/project/${projectId}`)
}
// ── Upload ──────────────────────────────────────────────────────────────────────
export async function initializeUpload(
key: string,
bucket: string,
contentType: string
): Promise<UploadInitResponse> {
if (usesSelfHosted()) {
return indeehubApiService.post<UploadInitResponse>('/upload/initialize', {
Key: key,
Bucket: bucket,
ContentType: contentType,
})
}
return apiService.post<UploadInitResponse>('/upload/initialize', {
Key: key,
Bucket: bucket,
ContentType: contentType,
})
}
export async function getPresignedUrls(
uploadId: string,
key: string,
bucket: string,
parts: number
): Promise<UploadPresignedUrlsResponse> {
if (usesSelfHosted()) {
return indeehubApiService.post<UploadPresignedUrlsResponse>('/upload/presigned-urls', {
UploadId: uploadId,
Key: key,
Bucket: bucket,
parts,
})
}
return apiService.post<UploadPresignedUrlsResponse>('/upload/presigned-urls', {
UploadId: uploadId,
Key: key,
Bucket: bucket,
parts,
})
}
export async function finalizeUpload(
uploadId: string,
key: string,
bucket: string,
parts: Array<{ PartNumber: number; ETag: string }>
): Promise<void> {
const payload = { UploadId: uploadId, Key: key, Bucket: bucket, parts }
if (usesSelfHosted()) {
await indeehubApiService.post('/upload/finalize', payload)
return
}
await apiService.post('/upload/finalize', payload)
}
// ── Analytics ───────────────────────────────────────────────────────────────────
export async function getFilmmakerAnalytics(): Promise<ApiFilmmakerAnalytics> {
if (usesSelfHosted()) {
return indeehubApiService.get<ApiFilmmakerAnalytics>('/filmmakers/analytics')
}
return apiService.get<ApiFilmmakerAnalytics>('/filmmakers/analytics')
}
export async function getWatchAnalytics(
projectIds?: string[],
dateRange?: { start: string; end: string }
): Promise<ApiWatchAnalytics> {
const params: Record<string, string> = {}
if (projectIds?.length) params['projectIds'] = projectIds.join(',')
if (dateRange) {
params['start'] = dateRange.start
params['end'] = dateRange.end
}
if (usesSelfHosted()) {
const qs = new URLSearchParams(params).toString()
return indeehubApiService.get<ApiWatchAnalytics>(`/filmmakers/watch-analytics${qs ? `?${qs}` : ''}`)
}
return apiService.get<ApiWatchAnalytics>('/filmmakers/watch-analytics', { params })
}
export async function getProjectRevenue(
projectIds: string[],
dateRange?: { start: string; end: string }
): Promise<Record<string, number>> {
const params: Record<string, string> = { ids: projectIds.join(',') }
if (dateRange) {
params['start'] = dateRange.start
params['end'] = dateRange.end
}
if (usesSelfHosted()) {
const qs = new URLSearchParams(params).toString()
return indeehubApiService.get<Record<string, number>>(`/projects/revenue${qs ? `?${qs}` : ''}`)
}
return apiService.get<Record<string, number>>('/projects/revenue', { params })
}
// ── Payments / Withdrawal ───────────────────────────────────────────────────────
export async function getPaymentMethods(): Promise<ApiPaymentMethod[]> {
if (usesSelfHosted()) {
return indeehubApiService.get<ApiPaymentMethod[]>('/payment-methods')
}
return apiService.get<ApiPaymentMethod[]>('/payment-methods')
}
export async function addPaymentMethod(data: {
type: 'lightning' | 'bank'
lightningAddress?: string
bankName?: string
accountNumber?: string
routingNumber?: string
withdrawalFrequency: 'manual' | 'weekly' | 'monthly'
}): Promise<ApiPaymentMethod> {
if (usesSelfHosted()) {
return indeehubApiService.post<ApiPaymentMethod>('/payment-methods', data)
}
return apiService.post<ApiPaymentMethod>('/payment-methods', data)
}
export async function updatePaymentMethod(
id: string,
data: Partial<ApiPaymentMethod>
): Promise<ApiPaymentMethod> {
if (usesSelfHosted()) {
return indeehubApiService.patch<ApiPaymentMethod>(`/payment-methods/${id}`, data)
}
return apiService.patch<ApiPaymentMethod>(`/payment-methods/${id}`, data)
}
export async function selectPaymentMethod(id: string): Promise<void> {
if (usesSelfHosted()) {
await indeehubApiService.patch(`/payment-methods/${id}/select`, {})
return
}
await apiService.patch(`/payment-methods/${id}/select`, {})
}
export async function removePaymentMethod(id: string): Promise<void> {
if (usesSelfHosted()) {
await indeehubApiService.delete(`/payment-methods/${id}`)
return
}
await apiService.delete(`/payment-methods/${id}`)
}
export async function validateLightningAddress(address: string): Promise<boolean> {
if (usesSelfHosted()) {
const res = await indeehubApiService.post<{ valid: boolean }>('/payment/validate-lightning-address', { address })
return res.valid
}
const res = await apiService.post<{ valid: boolean }>('/payment/validate-lightning-address', { address })
return res.valid
}
export async function getPayments(filmId?: string): Promise<ApiPayment[]> {
const params = filmId ? { filmId } : undefined
if (usesSelfHosted()) {
const qs = filmId ? `?filmId=${filmId}` : ''
return indeehubApiService.get<ApiPayment[]>(`/payment${qs}`)
}
return apiService.get<ApiPayment[]>('/payment', { params })
}
export async function withdraw(amount: number, filmId?: string): Promise<ApiPayment> {
const payload: Record<string, any> = { amount }
if (filmId) payload.filmId = filmId
if (usesSelfHosted()) {
return indeehubApiService.post<ApiPayment>('/payment/bank-payout', payload)
}
return apiService.post<ApiPayment>('/payment/bank-payout', payload)
}
// ── Genres ───────────────────────────────────────────────────────────────────────
export async function getGenres(): Promise<ApiGenre[]> {
if (usesSelfHosted()) {
return indeehubApiService.get<ApiGenre[]>('/genres')
}
return apiService.get<ApiGenre[]>('/genres')
}
// ── SAT Price ───────────────────────────────────────────────────────────────────
export async function getSatPrice(): Promise<number> {
if (usesSelfHosted()) {
const res = await indeehubApiService.get<{ price: number }>('/payment/sat-price')
return res.price
}
const res = await apiService.get<{ price: number }>('/payment/sat-price')
return res.price
}
/**
* Grouped export for convenience
*/
export const filmmakerService = {
// Projects
getPrivateProjects,
getPrivateProjectsCount,
getPrivateProject,
createProject,
updateProject,
deleteProject,
checkSlugExists,
// Content
getContents,
upsertContent,
getSeasons,
// Upload
initializeUpload,
getPresignedUrls,
finalizeUpload,
// Analytics
getFilmmakerAnalytics,
getWatchAnalytics,
getProjectRevenue,
// Payments
getPaymentMethods,
addPaymentMethod,
updatePaymentMethod,
selectPaymentMethod,
removePaymentMethod,
validateLightningAddress,
getPayments,
withdraw,
// Genres
getGenres,
// SAT Price
getSatPrice,
}

View File

@@ -0,0 +1,190 @@
/**
* IndeeHub Self-Hosted API Service
*
* Dedicated API client for our self-hosted NestJS backend.
* Uses the /api/ proxy configured in nginx.
* Auth tokens are managed by nip98.service.ts.
*/
import axios, { type AxiosInstance } from 'axios'
import { indeehubApiConfig } from '../config/api.config'
import { nip98Service } from './nip98.service'
class IndeehubApiService {
private client: AxiosInstance
constructor() {
this.client = axios.create({
baseURL: indeehubApiConfig.baseURL,
timeout: indeehubApiConfig.timeout,
headers: {
'Content-Type': 'application/json',
},
})
// Attach JWT token from NIP-98 session
this.client.interceptors.request.use((config) => {
const token = nip98Service.accessToken
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// Auto-refresh on 401
this.client.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
const newToken = await nip98Service.refresh()
if (newToken) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
return this.client(originalRequest)
}
}
return Promise.reject(error)
}
)
}
/**
* Generic typed GET request
*/
async get<T>(url: string): Promise<T> {
const response = await this.client.get<T>(url)
return response.data
}
/**
* Generic typed POST request
*/
async post<T>(url: string, data?: any): Promise<T> {
const response = await this.client.post<T>(url, data)
return response.data
}
/**
* Generic typed PATCH request
*/
async patch<T>(url: string, data?: any): Promise<T> {
const response = await this.client.patch<T>(url, data)
return response.data
}
/**
* Generic typed DELETE request
*/
async delete<T = void>(url: string): Promise<T> {
const response = await this.client.delete<T>(url)
return response.data
}
/**
* Check if the API is reachable
*/
async healthCheck(): Promise<boolean> {
try {
await this.client.get('/nostr-auth/health', { timeout: 5000 })
return true
} catch {
return false
}
}
/**
* Get all published projects
*/
async getProjects(filters?: {
status?: string
type?: string
genre?: string
limit?: number
offset?: number
}): Promise<any[]> {
const params = new URLSearchParams()
if (filters?.status) params.append('status', filters.status)
if (filters?.type) params.append('type', filters.type)
if (filters?.genre) params.append('genre', filters.genre)
if (filters?.limit) params.append('limit', String(filters.limit))
if (filters?.offset) params.append('offset', String(filters.offset))
const url = `/projects${params.toString() ? `?${params.toString()}` : ''}`
const response = await this.client.get(url)
return response.data
}
/**
* Get a single project by ID
*/
async getProject(id: string): Promise<any> {
const response = await this.client.get(`/projects/${id}`)
return response.data
}
/**
* Get streaming URL for a content item
* Returns different data based on deliveryMode (native vs partner)
*/
async getStreamingUrl(contentId: string): Promise<{
url: string
deliveryMode: 'native' | 'partner'
keyUrl?: string
drmToken?: string
}> {
const response = await this.client.get(`/contents/${contentId}/stream`)
return response.data
}
/**
* Get user's library items
*/
async getLibrary(): Promise<any[]> {
const response = await this.client.get('/library')
return response.data
}
/**
* Add a project to user's library
*/
async addToLibrary(projectId: string): Promise<any> {
const response = await this.client.post('/library', { projectId })
return response.data
}
/**
* Remove a project from user's library
*/
async removeFromLibrary(projectId: string): Promise<void> {
await this.client.delete(`/library/${projectId}`)
}
/**
* Get current user profile (requires auth)
*/
async getMe(): Promise<any> {
const response = await this.client.get('/auth/me')
return response.data
}
/**
* Get genres
*/
async getGenres(): Promise<any[]> {
const response = await this.client.get('/genres')
return response.data
}
/**
* Get the CDN URL for a storage path
*/
getCdnUrl(path: string): string {
if (!path) return ''
if (path.startsWith('http')) return path
if (path.startsWith('/')) return path
return `${indeehubApiConfig.cdnURL}/${path}`
}
}
export const indeehubApiService = new IndeehubApiService()

View File

@@ -1,5 +1,6 @@
import { apiService } from './api.service'
import type { ApiRent, ApiContent } from '../types/api'
import { USE_MOCK } from '../utils/mock'
/**
* Library Service
@@ -14,10 +15,7 @@ class LibraryService {
rented: ApiRent[]
continueWatching: Array<{ content: ApiContent; progress: number }>
}> {
// Check if we're in development mode
const useMockData = import.meta.env.VITE_USE_MOCK_DATA === 'true' || import.meta.env.DEV
if (useMockData) {
if (USE_MOCK) {
// Mock library data for development
console.log('🔧 Development mode: Using mock library data')
@@ -81,15 +79,31 @@ class LibraryService {
}
/**
* Rent content
* Rent content via Lightning invoice.
* Calls POST /rents/lightning which creates a BTCPay invoice.
* Returns the invoice details including BOLT11 for QR code display.
*/
async rentContent(contentId: string, paymentMethodId?: string): Promise<ApiRent> {
return apiService.post<ApiRent>('/rents', {
contentId,
paymentMethodId,
async rentContent(contentId: string, couponCode?: string): Promise<{
id: string
contentId: string
lnInvoice: string
expiration: string
sourceAmount: { amount: string; currency: string }
conversionRate: { amount: string; sourceCurrency: string; targetCurrency: string }
}> {
return apiService.post('/rents/lightning', {
id: contentId,
couponCode,
})
}
/**
* Check if a rent exists for a given content ID (for polling after payment).
*/
async checkRentExists(contentId: string): Promise<{ exists: boolean }> {
return apiService.get(`/rents/content/${contentId}/exists`)
}
/**
* Check if user has access to content
*/

View File

@@ -0,0 +1,172 @@
/**
* NIP-98 Authentication Bridge
*
* Handles Nostr-based authentication with the self-hosted backend:
* 1. Signs NIP-98 HTTP Auth events (kind 27235)
* 2. Exchanges them for JWT session tokens
* 3. Manages token storage and auto-refresh
*/
import axios from 'axios'
import { indeehubApiConfig } from '../config/api.config'
const TOKEN_KEY = 'indeehub_api_token'
const REFRESH_KEY = 'indeehub_api_refresh'
const EXPIRES_KEY = 'indeehub_api_expires'
class Nip98Service {
private refreshPromise: Promise<string> | null = null
/**
* Check if we have a valid (non-expired) API token
*/
get hasValidToken(): boolean {
const token = sessionStorage.getItem(TOKEN_KEY)
const expires = sessionStorage.getItem(EXPIRES_KEY)
if (!token || !expires) return false
return Date.now() < Number(expires)
}
/**
* Get the current access token
*/
get accessToken(): string | null {
if (!this.hasValidToken) return null
return sessionStorage.getItem(TOKEN_KEY)
}
/**
* Create a session with the backend using a NIP-98 auth event.
*
* The signer creates a kind 27235 event targeting POST /auth/nostr/session.
* The backend verifies the event and returns JWT tokens.
*
* @param signer - An applesauce signer instance (or any object with sign() method)
* @param pubkey - The Nostr pubkey (hex)
*/
async createSession(signer: any, pubkey: string): Promise<boolean> {
try {
const url = `${indeehubApiConfig.baseURL}/auth/nostr/session`
const now = Math.floor(Date.now() / 1000)
// Build the NIP-98 event
const event = {
kind: 27235,
created_at: now,
tags: [
['u', url],
['method', 'POST'],
],
content: '',
pubkey,
}
// Sign the event using the Nostr signer
let signedEvent: any
if (typeof signer.sign === 'function') {
signedEvent = await signer.sign(event)
} else if (typeof signer.signEvent === 'function') {
signedEvent = await signer.signEvent(event)
} else {
throw new Error('Signer does not have a sign or signEvent method')
}
// Base64-encode the signed event
const encodedEvent = btoa(JSON.stringify(signedEvent))
// Send to backend — no body to avoid NIP-98 payload mismatch
const response = await axios({
method: 'POST',
url,
headers: {
Authorization: `Nostr ${encodedEvent}`,
},
timeout: 15000,
})
const { accessToken, refreshToken, expiresIn } = response.data
// Store tokens
sessionStorage.setItem(TOKEN_KEY, accessToken)
sessionStorage.setItem(REFRESH_KEY, refreshToken)
sessionStorage.setItem(
EXPIRES_KEY,
String(Date.now() + (expiresIn * 1000) - 30000) // 30s buffer
)
// Also set on the main apiService for backwards compatibility
sessionStorage.setItem('nostr_token', accessToken)
return true
} catch (error) {
console.error('[nip98] Failed to create session:', error)
return false
}
}
/**
* Refresh the session using the stored refresh token
*/
async refresh(): Promise<string | null> {
if (this.refreshPromise) return this.refreshPromise
this.refreshPromise = (async () => {
try {
const refreshToken = sessionStorage.getItem(REFRESH_KEY)
if (!refreshToken) return null
const response = await axios.post(
`${indeehubApiConfig.baseURL}/auth/nostr/refresh`,
{ refreshToken },
{ timeout: 15000 }
)
const { accessToken, refreshToken: newRefresh, expiresIn } = response.data
sessionStorage.setItem(TOKEN_KEY, accessToken)
if (newRefresh) sessionStorage.setItem(REFRESH_KEY, newRefresh)
sessionStorage.setItem(
EXPIRES_KEY,
String(Date.now() + (expiresIn * 1000) - 30000)
)
sessionStorage.setItem('nostr_token', accessToken)
return accessToken
} catch {
this.clearSession()
return null
} finally {
this.refreshPromise = null
}
})()
return this.refreshPromise
}
/**
* Store tokens from an external auth flow (e.g. auth.service.ts).
* Keeps nip98Service in sync so IndeehubApiService can read the token.
*/
storeTokens(accessToken: string, refreshToken?: string, expiresIn?: number) {
sessionStorage.setItem(TOKEN_KEY, accessToken)
if (refreshToken) {
sessionStorage.setItem(REFRESH_KEY, refreshToken)
}
// Default to 1 hour if expiresIn is not provided
const ttlMs = (expiresIn ?? 3600) * 1000
sessionStorage.setItem(EXPIRES_KEY, String(Date.now() + ttlMs - 30000))
// Backwards compatibility
sessionStorage.setItem('nostr_token', accessToken)
}
/**
* Clear stored session data
*/
clearSession() {
sessionStorage.removeItem(TOKEN_KEY)
sessionStorage.removeItem(REFRESH_KEY)
sessionStorage.removeItem(EXPIRES_KEY)
}
}
export const nip98Service = new Nip98Service()

View File

@@ -46,6 +46,22 @@ class SubscriptionService {
return apiService.post<ApiSubscription>(`/subscriptions/${subscriptionId}/resume`)
}
/**
* Create a Lightning subscription invoice via BTCPay.
* Returns invoice details including BOLT11 for QR code display.
*/
async createLightningSubscription(data: {
type: 'enthusiast' | 'film-buff' | 'cinephile'
period: 'monthly' | 'annual'
}): Promise<{
lnInvoice: string
expiration: string
sourceAmount: { amount: string; currency: string }
id: string
}> {
return apiService.post('/subscriptions/lightning', data)
}
/**
* Update payment method
*/