diff --git a/.cursor/rules/quality.mdc b/.cursor/rules/quality.mdc
index e9df6d7..e490621 100644
--- a/.cursor/rules/quality.mdc
+++ b/.cursor/rules/quality.mdc
@@ -264,3 +264,119 @@ Ces consignes constituent un cadre de production strict. Elles imposent une anal
* ARIA
* clavier
* contraste
+
+## Open Source
+
+Le projet est open source et hébergé sur Gitea auto-hébergé. Toutes les contributions doivent respecter les principes open source suivants.
+
+### Principes open source
+
+* **Transparence** : Tous les changements doivent être traçables, documentés et accessibles publiquement
+* **Collaboration** : Le code doit être conçu pour faciliter les contributions externes
+* **Documentation** : Toute fonctionnalité ou modification doit être documentée pour les contributeurs externes
+* **Standards ouverts** : Utiliser des standards ouverts et éviter les dépendances propriétaires non documentées
+* **Licence MIT** : Le code doit respecter la licence MIT et être compatible avec cette licence
+
+### Contribution et workflow open source
+
+* **Commits systématiques** : Tous les changements doivent être commités avec des messages structurés
+* **Format de commit** : Les commits doivent suivre le format défini dans CONTRIBUTING.md avec sections Motivations, Root causes, Correctifs, Evolutions, Pages affectées
+* **Pull Requests** : Tous les changements doivent passer par des Pull Requests sur Gitea, même pour les modifications internes
+* **Documentation des changements** : Toute modification doit être documentée dans `fixKnowledge/` (pour les corrections) ou `features/` (pour les évolutions)
+* **Traçabilité** : Chaque modification doit être liée à une issue ou une PR sur Gitea si applicable
+
+### Qualité pour contributions externes
+
+* **Code lisible** : Le code doit être compréhensible par des contributeurs externes sans connaissance préalable du contexte interne
+* **Commentaires explicatifs** : Les choix de conception non évidents doivent être commentés
+* **Documentation à jour** : La documentation doit toujours refléter l'état actuel du code
+* **Exemples et guides** : Fournir des exemples d'utilisation et des guides pour les nouvelles fonctionnalités
+* **Tests et validation** : Le code doit être validé et testable par des contributeurs externes
+
+### Gestion des contributions
+
+* **Respect du Code of Conduct** : Tous les contributeurs doivent respecter le CODE_OF_CONDUCT.md
+* **Review process** : Toutes les contributions doivent être revues avant merge
+* **Feedback constructif** : Fournir un feedback constructif et respectueux sur les contributions
+* **Attribution** : Créditer les contributeurs dans les commits et la documentation
+
+### Sécurité open source
+
+* **Reporting de vulnérabilités** : Suivre le processus défini dans SECURITY.md
+* **Pas de secrets** : Aucun secret, clé API ou credential ne doit être commité
+* **Dépendances sécurisées** : Vérifier la sécurité des dépendances avant ajout
+* **Audit de sécurité** : Les changements de sécurité doivent être documentés et audités
+
+### Repository et infrastructure
+
+* **Repository Gitea** : https://git.4nkweb.com/4nk/story-research-zapwall
+* **Templates** : Utiliser les templates d'issues et de PR dans `.gitea/`
+* **Labels et organisation** : Utiliser les labels appropriés pour organiser les issues et PRs
+* **Branches** : Respecter la convention de nommage des branches (feature/, fix/, etc.)
+
+## Commits systématiques
+
+**Règle absolue** : Tous les changements doivent être commités immédiatement après leur réalisation.
+
+### Obligation de commit
+
+* **Pas de modifications non commitées** : Aucune modification ne doit rester non commitée
+* **Commits atomiques** : Chaque commit doit représenter une modification logique et complète
+* **Commits fréquents** : Commiter régulièrement, pas seulement à la fin d'une session
+* **Pas de stash prolongé** : Éviter de laisser des modifications en stash sans les commiter
+
+### Format de commit obligatoire
+
+Tous les commits doivent suivre ce format structuré :
+
+```
+Titre court et descriptif
+
+**Motivations:**
+- Raison de la modification
+
+**Root causes:**
+- Cause racine du problème (si applicable)
+
+**Correctifs:**
+- Ce qui a été corrigé
+
+**Evolutions:**
+- Nouvelles fonctionnalités ou améliorations
+
+**Pages affectées:**
+- Liste des fichiers/modules modifiés
+```
+
+### Processus de commit
+
+1. **Avant chaque commit** :
+ - Vérifier que le code compile (`npm run type-check`)
+ - Vérifier le linting (`npm run lint`)
+ - Vérifier que les modifications sont complètes et fonctionnelles
+
+2. **Création du commit** :
+ - Utiliser `git add` pour les fichiers modifiés
+ - Créer un commit avec le format structuré
+ - Ne pas utiliser `--no-verify` sauf cas exceptionnel documenté
+
+3. **Après le commit** :
+ - Vérifier que le commit a bien été créé (`git log`)
+ - Documenter dans `fixKnowledge/` ou `features/` si nécessaire
+
+### Exceptions
+
+Les seules exceptions à la règle de commit immédiat sont :
+
+* **Modifications en cours de test** : Si une modification nécessite des tests manuels avant commit, elle doit être commitée dès que les tests sont validés
+* **Refactoring en plusieurs étapes** : Les refactorings complexes peuvent être commités par étapes logiques
+* **Documentation en cours** : La documentation peut être commitée séparément du code si elle est volumineuse
+
+Dans tous les cas, aucun changement ne doit rester non commité plus de quelques heures.
+
+### Intégration avec le workflow open source
+
+* **Commits avant PR** : Tous les commits doivent être faits avant la création d'une Pull Request
+* **Commits dans les PRs** : Les commits dans une PR doivent être organisés et logiques
+* **Squash si nécessaire** : Les commits peuvent être squashés dans une PR si cela améliore la lisibilité, mais chaque commit individuel doit rester valide
+* **Historique propre** : Maintenir un historique Git propre et lisible pour les contributeurs externes
\ No newline at end of file
diff --git a/.gitea/ISSUE_TEMPLATE/bug_report.md b/.gitea/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..ed8ee16
--- /dev/null
+++ b/.gitea/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,52 @@
+---
+name: Bug Report
+about: Create a report to help us improve
+title: '[BUG] '
+labels: bug
+---
+
+## Bug Description
+
+A clear and concise description of what the bug is.
+
+## Steps to Reproduce
+
+1. Go to '...'
+2. Click on '....'
+3. Scroll down to '....'
+4. See error
+
+## Expected Behavior
+
+A clear and concise description of what you expected to happen.
+
+## Actual Behavior
+
+A clear and concise description of what actually happened.
+
+## Screenshots
+
+If applicable, add screenshots to help explain your problem.
+
+## Environment
+
+- **Browser**: [e.g. Chrome 120, Firefox 121]
+- **OS**: [e.g. Windows 11, macOS 14, Linux]
+- **Alby Extension**: [e.g. Version 5.2.0]
+- **Node Version**: [if relevant]
+
+## Console Errors
+
+If applicable, paste any console errors here:
+
+```
+Paste console errors here
+```
+
+## Additional Context
+
+Add any other context about the problem here.
+
+## Possible Solution
+
+If you have ideas on how to fix this, please describe them here.
diff --git a/.gitea/ISSUE_TEMPLATE/config.yml b/.gitea/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..10eef24
--- /dev/null
+++ b/.gitea/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Security Vulnerability
+ url: https://git.4nkweb.com/4nk/story-research-zapwall/issues/new?template=security
+ about: Please report security vulnerabilities privately (use [SECURITY] prefix)
+ - name: Documentation
+ url: https://git.4nkweb.com/4nk/story-research-zapwall/src/branch/main/docs
+ about: Check our documentation for answers
diff --git a/.gitea/ISSUE_TEMPLATE/feature_request.md b/.gitea/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 0000000..607c1c9
--- /dev/null
+++ b/.gitea/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,46 @@
+---
+name: Feature Request
+about: Suggest an idea for this project
+title: '[FEATURE] '
+labels: enhancement
+---
+
+## Feature Description
+
+A clear and concise description of the feature you'd like to see.
+
+## Problem Statement
+
+Is your feature request related to a problem? Please describe.
+A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+
+## Proposed Solution
+
+Describe the solution you'd like to see implemented.
+
+## Alternatives Considered
+
+Describe any alternative solutions or features you've considered.
+
+## Use Cases
+
+Describe specific use cases for this feature:
+1. Use case 1
+2. Use case 2
+3. Use case 3
+
+## Technical Considerations
+
+If applicable, describe any technical considerations:
+- Performance implications
+- Security considerations
+- Integration with existing features
+- Breaking changes
+
+## Additional Context
+
+Add any other context, mockups, or examples about the feature request here.
+
+## Implementation Notes
+
+If you have ideas on how to implement this, please share them here.
diff --git a/.gitea/ISSUE_TEMPLATE/question.md b/.gitea/ISSUE_TEMPLATE/question.md
new file mode 100644
index 0000000..a00e62b
--- /dev/null
+++ b/.gitea/ISSUE_TEMPLATE/question.md
@@ -0,0 +1,25 @@
+---
+name: Question
+about: Ask a question about the project
+title: '[QUESTION] '
+labels: question
+---
+
+## Question
+
+Your question here.
+
+## Context
+
+Provide context for your question:
+- What are you trying to accomplish?
+- What have you tried so far?
+- What documentation have you consulted?
+
+## Relevant Documentation
+
+- [Link to relevant docs](url)
+
+## Additional Information
+
+Any other information that might help answer your question.
diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..12920a3
--- /dev/null
+++ b/.gitea/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,56 @@
+## Description
+
+Please include a summary of the change and which issue is fixed. Include relevant motivation and context.
+
+Fixes # (issue)
+
+## Type of Change
+
+- [ ] Bug fix (non-breaking change which fixes an issue)
+- [ ] New feature (non-breaking change which adds functionality)
+- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
+- [ ] Documentation update
+- [ ] Code refactoring
+
+## Changes Made
+
+**Motivations:**
+-
+
+**Root causes:**
+-
+
+**Correctifs:**
+-
+
+**Evolutions:**
+-
+
+**Pages affectées:**
+-
+
+## Testing
+
+- [ ] I have tested my changes locally
+- [ ] I have run `npm run lint` and it passes
+- [ ] I have run `npm run type-check` and it passes
+- [ ] I have tested the changes in the browser with Alby extension
+- [ ] I have verified accessibility (keyboard navigation, ARIA, contrast)
+
+## Checklist
+
+- [ ] My code follows the project's coding guidelines
+- [ ] I have performed a self-review of my own code
+- [ ] I have commented my code, particularly in hard-to-understand areas
+- [ ] I have made corresponding changes to the documentation
+- [ ] My changes generate no new warnings or errors
+- [ ] I have added tests if applicable (only if explicitly requested)
+- [ ] Any dependent changes have been merged and published
+
+## Screenshots
+
+If applicable, add screenshots to help explain your changes.
+
+## Additional Notes
+
+Add any additional notes about the PR here.
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..0cdde23
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,119 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, religion, or sexual identity
+and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our
+community include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or
+ advances of any kind
+* Trolling, insulting/derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email
+ address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official project e-mail
+address, posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at
+[INSERT CONTACT METHOD]. All complaints will be reviewed and investigated
+promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+**Community Impact**: A violation through a single incident or series
+of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or
+permanent ban.
+
+### 3. Temporary Ban
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within
+the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
+version 2.0, available at
+https://www.contributor-covenant.org/version/2/0/code_of_conduct.html
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct
+enforcement ladder](https://github.com/mozilla/diversity).
+
+For answers to common questions about this code of conduct, see
+https://www.contributor-covenant.org/faq
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 53c2420..b11ea71 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,31 +1,240 @@
-# Contributing to zapwall4science
+# Contributing to zapwall4Science
-## Principles
-- No fallbacks or silent failures.
-- No analytics; no tests added unless explicitly requested.
-- Respect lint, type-check, accessibility and exactOptionalPropertyTypes.
-- No `ts-ignore`, no untyped `any`, no console logs if a logger exists.
+Thank you for your interest in contributing to zapwall4Science! This document provides guidelines and instructions for contributing to the project.
-## Setup
-- Node 18+, npm
-- `npm install`
-- `npm run lint`
-- `npm run type-check`
+## Table of Contents
-## Coding guidelines
-- Split large components/functions to stay within lint limits (max-lines, max-lines-per-function).
-- Prefer typed helpers/hooks; avoid duplication.
-- Errors must surface with clear messages; do not swallow exceptions.
-- Storage: IndexedDB encrypted (AES-GCM) via `lib/storage/cryptoHelpers.ts`; use provided helpers.
-- Nostr: use `lib/articleMutations.ts` and `lib/nostr*.ts` helpers; no direct fallbacks.
+- [Code of Conduct](#code-of-conduct)
+- [Getting Started](#getting-started)
+- [Development Setup](#development-setup)
+- [Coding Guidelines](#coding-guidelines)
+- [Workflow](#workflow)
+- [Commit Guidelines](#commit-guidelines)
+- [Pull Request Process](#pull-request-process)
+- [Documentation](#documentation)
+- [What Not to Do](#what-not-to-do)
+
+## Code of Conduct
+
+By participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md). We are committed to providing a welcoming and inclusive environment for all contributors.
+
+## Getting Started
+
+1. **Fork the repository** on Gitea
+2. **Clone your fork** locally:
+ ```bash
+ git clone https://git.4nkweb.com/your-username/story-research-zapwall.git
+ cd story-research-zapwall
+ ```
+3. **Add the upstream remote**:
+ ```bash
+ git remote add upstream https://git.4nkweb.com/4nk/story-research-zapwall.git
+ ```
+
+## Development Setup
+
+### Prerequisites
+
+- **Node.js**: 18 or higher
+- **npm**: Latest version
+- **Alby browser extension**: For testing Nostr authentication and Lightning payments
+
+### Installation
+
+1. Install dependencies:
+ ```bash
+ npm install
+ ```
+
+2. Run linting:
+ ```bash
+ npm run lint
+ ```
+
+3. Run type checking:
+ ```bash
+ npm run type-check
+ ```
+
+4. Start the development server:
+ ```bash
+ npm run dev
+ ```
+
+5. Open [http://localhost:3000](http://localhost:3000) in your browser
+
+## Coding Guidelines
+
+### Core Principles
+
+- **No fallbacks or silent failures**: All errors must be explicitly handled and logged
+- **No analytics**: Analytics are not allowed in this project
+- **No tests unless explicitly requested**: Do not add ad-hoc tests
+- **Strict TypeScript**: Respect lint, type-check, accessibility, and `exactOptionalPropertyTypes`
+- **No shortcuts**: No `ts-ignore`, no untyped `any`, no `console.log` if a logger exists
+
+### Code Quality
+
+- **Split large components/functions**: Stay within lint limits (max-lines, max-lines-per-function)
+- **Prefer typed helpers/hooks**: Avoid duplication, reuse existing utilities
+- **Error handling**: Errors must surface with clear messages; do not swallow exceptions
+- **Storage**: Use IndexedDB encrypted (AES-GCM) via `lib/storage/cryptoHelpers.ts`
+- **Nostr**: Use `lib/articleMutations.ts` and `lib/nostr*.ts` helpers; no direct fallbacks
+
+### TypeScript Standards
+
+- Full type coverage: No `any` types without explicit justification
+- No type assertions: Avoid `as` casts unless absolutely necessary
+- Strict mode: All TypeScript strict checks must pass
+- No `@ts-ignore` or `@ts-nocheck`: Fix type issues properly
+
+### Accessibility
+
+- **ARIA**: Proper ARIA labels and roles
+- **Keyboard navigation**: All interactive elements must be keyboard accessible
+- **Contrast**: Meet WCAG contrast requirements
+- **No regressions**: Maintain or improve accessibility with each change
## Workflow
-- Branch from main; keep commits focused.
-- Run lint + type-check before PR.
-- Document fixes in `fixKnowledge/` and features in `features/`.
-## Accessibility
-- Respect ARIA, keyboard, contrast requirements; no regressions.
+### Creating a Branch
-## What not to do
-- No analytics, no ad-hoc tests, no environment overrides, no silent retry/fallback.
+1. **Update your fork**:
+ ```bash
+ git checkout main
+ git pull upstream main
+ ```
+
+2. **Create a feature branch**:
+ ```bash
+ git checkout -b feature/your-feature-name
+ # or
+ git checkout -b fix/your-bug-fix
+ ```
+
+### Making Changes
+
+1. Make your changes following the [coding guidelines](#coding-guidelines)
+2. Test your changes locally
+3. Run lint and type-check:
+ ```bash
+ npm run lint
+ npm run type-check
+ ```
+
+### Before Submitting
+
+- [ ] Code follows the project's coding guidelines
+- [ ] Lint passes (`npm run lint`)
+- [ ] Type-check passes (`npm run type-check`)
+- [ ] No `console.log` statements (use logger if available)
+- [ ] Errors are properly handled and logged
+- [ ] Accessibility requirements are met
+- [ ] Documentation is updated if needed
+
+## Commit Guidelines
+
+### Commit Message Format
+
+Commits should be exhaustive and synthetic with the following structure:
+
+```
+**Motivations:**
+- Reason for the change
+
+**Root causes:**
+- Underlying issue (if applicable)
+
+**Correctifs:**
+- What was fixed
+
+**Evolutions:**
+- New features or improvements
+
+**Pages affectées:**
+- Files or components modified
+```
+
+### Example
+
+```
+Fix payment modal not closing after successful payment
+
+**Motivations:**
+- Users reported payment modal staying open after successful payment
+
+**Root causes:**
+- Missing state update after payment confirmation
+
+**Correctifs:**
+- Added state reset in payment success handler
+- Added cleanup in useEffect hook
+
+**Evolutions:**
+- Improved user experience with automatic modal closure
+
+**Pages affectées:**
+- components/PaymentModal.tsx
+- hooks/useArticlePayment.ts
+```
+
+## Pull Request Process
+
+1. **Update your branch**:
+ ```bash
+ git checkout main
+ git pull upstream main
+ git checkout your-branch-name
+ git rebase main
+ ```
+
+2. **Push your changes**:
+ ```bash
+ git push origin your-branch-name
+ ```
+
+3. **Create a Pull Request** on Gitea:
+ - Use a clear, descriptive title
+ - Fill out the PR template completely
+ - Reference any related issues
+ - Add screenshots if UI changes are involved
+
+4. **Wait for review**: Maintainers will review your PR and provide feedback
+
+5. **Address feedback**: Make requested changes and push updates to your branch
+
+6. **Documentation**:
+ - Document fixes in `fixKnowledge/` directory
+ - Document features in `features/` directory
+
+## Documentation
+
+### When to Document
+
+- **Fixes**: Document in `fixKnowledge/` with problem, impacts, root cause, corrections, modifications, deployment, and analysis procedures
+- **Features**: Document in `features/` with objective, impacts, modifications, deployment, and analysis procedures
+
+### Documentation Format
+
+Follow the existing documentation structure:
+- Clear sections with headers
+- Code examples where relevant
+- Links to related documentation
+- Author attribution (Équipe 4NK)
+
+## What Not to Do
+
+- ❌ **No analytics**: Do not add analytics tracking
+- ❌ **No ad-hoc tests**: Do not add tests unless explicitly requested
+- ❌ **No environment overrides**: Do not override environment variables
+- ❌ **No silent retry/fallback**: All failures must be explicit
+- ❌ **No shortcuts**: No `ts-ignore`, no `any`, no `console.log`
+- ❌ **No breaking changes**: Maintain backward compatibility unless explicitly required
+
+## Getting Help
+
+- **Documentation**: Check the [docs/](docs/) directory
+- **Issues**: Search existing issues or create a new one on [Gitea](https://git.4nkweb.com/4nk/story-research-zapwall/issues)
+- **Repository**: [https://git.4nkweb.com/4nk/story-research-zapwall](https://git.4nkweb.com/4nk/story-research-zapwall)
+
+Thank you for contributing to zapwall4Science! 🚀
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7a8d862
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 zapwall4Science Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index e29cd4f..c5effc6 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,24 @@
# zapwall4Science
+[](https://opensource.org/licenses/MIT)
+[](https://www.typescriptlang.org/)
+[](https://nextjs.org/)
+
Plateforme de publication d'articles scientifiques et de science-fiction avec système de sponsoring, commissions et rémunération des avis. Les lecteurs peuvent lire les aperçus gratuitement et débloquer le contenu complet en payant avec Lightning Network.
+**Repository**: [https://git.4nkweb.com/4nk/story-research-zapwall](https://git.4nkweb.com/4nk/story-research-zapwall)
+
+## Table of Contents
+
+- [Features](#features)
+- [Getting Started](#getting-started)
+- [Configuration](#configuration)
+- [Lightning Wallet Setup](#lightning-wallet-setup)
+- [Project Structure](#project-structure)
+- [Contributing](#contributing)
+- [Documentation](#documentation)
+- [License](#license)
+
## Features
- **Nostr Authentication**: Authenticate using Alby browser extension (NIP-07)
@@ -25,23 +42,24 @@ npm run dev
3. Open [http://localhost:3000](http://localhost:3000) in your browser
-## Environment Variables
+## Configuration
-- `NEXT_PUBLIC_NOSTR_RELAY_URL`: Nostr relay URL (default: wss://relay.damus.io)
-- `NEXT_PUBLIC_NIP95_UPLOAD_URL`: NIP-95 media upload endpoint URL (required for image/video uploads)
+The application stores all configuration in IndexedDB (browser storage) with hardcoded defaults. No environment variables are required.
-### NIP-95 Upload Service
+### Default Configuration
-The application requires a NIP-95 compatible upload service for media uploads (images and videos). You can use services like:
-- [nostr.build](https://nostr.build/) - Public NIP-95 service
-- [void.cat](https://void.cat/) - Another public NIP-95 service
-- Or host your own NIP-95 compatible service
+- **Nostr Relay**: `wss://relay.damus.io` (default)
+- **NIP-95 Upload API**: `https://nostr.build/api/v2/upload` (default)
+- **Platform Lightning Address**: Empty by default
-Example `.env.local`:
-```
-NEXT_PUBLIC_NOSTR_RELAY_URL=wss://relay.damus.io
-NEXT_PUBLIC_NIP95_UPLOAD_URL=https://nostr.build/api/v2/upload
-```
+### Customizing Configuration
+
+Configuration is stored in IndexedDB and can be customized through the application settings. The application supports:
+- Multiple Nostr relays (with priority ordering)
+- Multiple NIP-95 upload APIs (with priority ordering)
+- Platform Lightning address for commissions
+
+All configuration values are stored locally in the browser and persist across sessions. Default values are hardcoded in the application code.
## Lightning Wallet Setup
@@ -58,3 +76,75 @@ Users need to have Alby installed to authenticate and make payments. The applica
- `/lib`: Utilities and Nostr helpers
- `/types`: TypeScript type definitions
- `/hooks`: Custom React hooks
+
+## Déploiement
+
+### Documentation complète
+
+La documentation complète du déploiement est disponible dans le dossier `docs/` :
+
+- **[Documentation complète du déploiement](docs/deployment.md)** : Guide détaillé de déploiement, configuration et maintenance
+- **[Référence des scripts](docs/scripts-reference.md)** : Description de tous les scripts disponibles
+- **[Guide de référence rapide](docs/quick-reference.md)** : Commandes essentielles
+
+### Déploiement rapide
+
+Le site est déployé sur `zapwall.fr` (serveur : `92.243.27.35`).
+
+**Mise à jour du site** :
+
+```bash
+# Méthode recommandée : Script automatique
+./update-remote-git.sh
+```
+
+**Vérification du statut** :
+
+```bash
+ssh debian@92.243.27.35 'sudo systemctl status zapwall'
+```
+
+### Informations de déploiement
+
+- **Répertoire** : `/var/www/zapwall.fr`
+- **Port application** : `3001`
+- **Service systemd** : `zapwall.service`
+- **Nginx** : Conteneur Docker `lecoffre_nginx_test`
+- **HTTPS** : Configuré avec redirection automatique HTTP → HTTPS
+
+Pour plus de détails, consultez la [documentation complète](docs/deployment.md).
+
+## Contributing
+
+We welcome contributions! Please read our [Contributing Guide](CONTRIBUTING.md) to get started.
+
+### How to Contribute
+
+1. Fork the repository
+2. Create a feature branch (`git checkout -b feature/amazing-feature`)
+3. Make your changes following our [coding guidelines](CONTRIBUTING.md#coding-guidelines)
+4. Run lint and type-check (`npm run lint && npm run type-check`)
+5. Commit your changes (`git commit -m 'Add amazing feature'`)
+6. Push to the branch (`git push origin feature/amazing-feature`)
+7. Open a Pull Request
+
+Please ensure your code follows our strict quality standards:
+- No fallbacks or silent failures
+- Full TypeScript typing (no `any`, no `ts-ignore`)
+- Proper error handling and logging
+- Accessibility compliance (ARIA, keyboard navigation, contrast)
+
+See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
+
+## Documentation
+
+- **[User Guide](docs/user-guide.md)**: Complete user documentation
+- **[Technical Documentation](docs/technical.md)**: Architecture and technical details
+- **[Deployment Guide](docs/deployment.md)**: Deployment and configuration
+- **[FAQ](docs/faq.md)**: Frequently asked questions
+- **[Publishing Guide](docs/publishing-guide.md)**: How to publish articles
+- **[Payment Guide](docs/payment-guide.md)**: Lightning payment setup
+
+## License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
\ No newline at end of file
diff --git a/RESUME-DEPLOIEMENT.md b/RESUME-DEPLOIEMENT.md
new file mode 100644
index 0000000..8af0488
--- /dev/null
+++ b/RESUME-DEPLOIEMENT.md
@@ -0,0 +1,143 @@
+# Résumé du déploiement - zapwall.fr
+
+## ✅ Actions effectuées
+
+1. **Déploiement initial** : Application déployée sur `/var/www/zapwall.fr`
+2. **Service systemd** : Service `zapwall.service` créé et actif
+3. **Configuration nginx** : Reverse proxy configuré dans le conteneur Docker
+4. **HTTPS** : Configuration HTTPS avec redirection automatique HTTP → HTTPS
+5. **Firewall** : Ports 80 et 443 ouverts
+
+## 📍 État actuel
+
+- **Service** : ✅ Actif sur le port 3001
+- **Répertoire** : `/var/www/zapwall.fr` (correct)
+- **HTTPS** : ✅ Configuré avec certificats auto-signés
+- **Certificats Let's Encrypt** : ⚠️ Non obtenus (bug certbot avec Python 3.11)
+
+## 🔧 Problèmes identifiés et solutions
+
+### 1. Certificats Let's Encrypt
+
+**Problème** : Certbot présente un bug (AttributeError) avec Python 3.11
+
+**Solutions** :
+- Utiliser certbot via snap (recommandé) :
+ ```bash
+ ssh debian@92.243.27.35
+ sudo snap install certbot --classic
+ sudo docker stop lecoffre_nginx_test
+ sudo certbot certonly --standalone -d zapwall.fr --non-interactive --agree-tos --email admin@zapwall.fr
+ sudo docker start lecoffre_nginx_test
+ ```
+
+### 2. Configuration nginx
+
+**Vérification** : La configuration pointe bien vers le port 3001 (correct)
+- `proxy_pass http://172.17.0.1:3001;` ✅
+
+**Note** : La configuration principale nginx.conf contient aussi une config pour `test-lecoffreio.4nkweb.com`, mais elle ne devrait pas interférer car elle utilise un `server_name` différent.
+
+## 📝 Mise à jour du site depuis Git
+
+### Méthode recommandée : Script automatique
+
+Depuis votre machine locale, dans le répertoire du projet :
+
+```bash
+# Mise à jour depuis la branche actuelle
+./update-from-git.sh
+
+# Ou spécifier une branche
+./update-from-git.sh main
+```
+
+Le script :
+1. Transfère les fichiers depuis votre dépôt Git local
+2. Installe les dépendances (`npm ci`)
+3. Construit l'application (`npm run build`)
+4. Redémarre le service
+5. Vérifie que tout fonctionne
+
+### Méthode manuelle
+
+Si vous préférez faire manuellement :
+
+```bash
+# 1. Transférer les fichiers
+tar --exclude='node_modules' \
+ --exclude='.next' \
+ --exclude='.git' \
+ --exclude='*.tsbuildinfo' \
+ --exclude='.env*.local' \
+ --exclude='.cursor' \
+ -czf - . | ssh debian@92.243.27.35 "cd /var/www/zapwall.fr && tar -xzf -"
+
+# 2. Sur le serveur
+ssh debian@92.243.27.35
+cd /var/www/zapwall.fr
+npm ci
+npm run build
+sudo systemctl restart zapwall
+```
+
+### Initialiser un dépôt Git sur le serveur (optionnel)
+
+Si vous voulez pouvoir faire `git pull` directement sur le serveur :
+
+```bash
+ssh debian@92.243.35
+cd /var/www/zapwall.fr
+git init
+git remote add origin https://git.4nkweb.com/4nk/story-research-zapwall.git
+git fetch origin
+git checkout main # ou la branche souhaitée
+```
+
+Ensuite, vous pourrez utiliser :
+```bash
+ssh debian@92.243.27.35 'cd /var/www/zapwall.fr && git pull && npm ci && npm run build && sudo systemctl restart zapwall'
+```
+
+## 🔍 Commandes de vérification
+
+### Vérifier le service
+```bash
+ssh debian@92.243.27.35 'sudo systemctl status zapwall'
+```
+
+### Voir les logs
+```bash
+ssh debian@92.243.27.35 'sudo journalctl -u zapwall -f'
+```
+
+### Vérifier le port
+```bash
+ssh debian@92.243.27.35 'sudo ss -tuln | grep 3001'
+```
+
+### Vérifier la configuration nginx
+```bash
+ssh debian@92.243.27.35 'sudo docker exec lecoffre_nginx_test cat /etc/nginx/conf.d/zapwall.fr.conf'
+```
+
+## 📚 Fichiers de documentation créés
+
+- `README-DEPLOYMENT.md` : Guide complet de déploiement et mise à jour
+- `update-from-git.sh` : Script de mise à jour automatique
+- `fix-nginx-config.sh` : Script de correction de la configuration
+- `check-deployment-status.sh` : Script de vérification de l'état
+
+## 🚀 Prochaines étapes recommandées
+
+1. **Obtenir les certificats Let's Encrypt** via snap (voir ci-dessus)
+2. **Configurer le renouvellement automatique** des certificats
+3. **Tester l'accès** au site en HTTPS
+4. **Configurer un dépôt Git sur le serveur** pour faciliter les mises à jour
+
+## ⚠️ Notes importantes
+
+- Le site fonctionne actuellement avec des certificats auto-signés (avertissement navigateur)
+- Les modifications de code nécessitent un rebuild et un redémarrage du service
+- Le service doit être actif pour que le site soit accessible
+- Nginx fait un reverse proxy vers le port 3001 où tourne l'application Next.js
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..595642b
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,142 @@
+# Security Policy
+
+## Supported Versions
+
+We provide security updates for the following versions:
+
+| Version | Supported |
+| ------- | ------------------ |
+| 1.0.x | :white_check_mark: |
+| < 1.0 | :x: |
+
+## Reporting a Vulnerability
+
+We take security vulnerabilities seriously. If you discover a security vulnerability, please follow these steps:
+
+### 1. **Do NOT** create a public issue
+
+Security vulnerabilities should be reported privately to protect users.
+
+### 2. Report via Private Issue
+
+1. Go to the [Issues](https://git.4nkweb.com/4nk/story-research-zapwall/issues) page
+2. Create a new issue with the title prefixed with `[SECURITY]`
+3. Mark the issue as confidential/private if Gitea supports it
+4. Fill out the form with:
+ - **Title**: Brief description of the vulnerability (prefixed with `[SECURITY]`)
+ - **Description**: Detailed description of the vulnerability
+ - **Severity**: Assess the severity (Low, Moderate, High, Critical)
+ - **Affected versions**: Which versions are affected
+ - **Steps to reproduce**: How to reproduce the vulnerability
+ - **Impact**: What could an attacker do with this vulnerability
+
+### 3. Alternative Reporting Methods
+
+If you cannot create a private issue, please contact the maintainers directly through secure channels. Do not disclose the vulnerability publicly until it has been addressed.
+
+### 4. What to Include
+
+Please include the following information in your report:
+
+- **Type of vulnerability** (e.g., XSS, CSRF, authentication bypass, etc.)
+- **Location** (file path, component, or endpoint)
+- **Steps to reproduce** (detailed steps)
+- **Potential impact** (what could an attacker do?)
+- **Suggested fix** (if you have one)
+- **Proof of concept** (if applicable, but be careful not to include exploits)
+
+### 5. Response Timeline
+
+- **Initial response**: Within 48 hours
+- **Status update**: Within 7 days
+- **Fix timeline**: Depends on severity
+ - **Critical**: As soon as possible (typically within 24-48 hours)
+ - **High**: Within 1 week
+ - **Medium**: Within 2 weeks
+ - **Low**: Within 1 month
+
+### 6. Disclosure Policy
+
+- We will acknowledge receipt of your report within 48 hours
+- We will keep you informed of our progress
+- We will notify you when the vulnerability is fixed
+- We will credit you in the security advisory (unless you prefer to remain anonymous)
+
+## Security Best Practices
+
+### For Contributors
+
+- **Never commit secrets**: API keys, private keys, passwords, etc.
+- **Use secure dependencies**: Keep dependencies up to date
+- **Follow secure coding practices**: Input validation, output encoding, etc.
+- **Review security implications**: Consider security impact of changes
+
+### For Users
+
+- **Keep dependencies updated**: Run `npm audit` regularly
+- **Use HTTPS**: Always use HTTPS in production
+- **Secure your Nostr keys**: Never share your private keys
+- **Use secure Lightning wallets**: Only use trusted Lightning wallet extensions
+
+## Known Security Considerations
+
+### Nostr Authentication
+
+- Private keys are managed by the Alby extension (NIP-07)
+- We never store or transmit private keys
+- All Nostr operations are signed client-side
+
+### Lightning Payments
+
+- Payment requests are generated via WebLN (Alby extension)
+- We do not handle private keys or payment secrets
+- All payment operations are performed by the user's wallet
+
+### Data Storage
+
+- Private content is encrypted using AES-GCM
+- Encryption keys are stored in browser localStorage
+- No sensitive data is sent to external servers (except Nostr relays)
+
+### IndexedDB
+
+- Encrypted content is stored in IndexedDB
+- Keys are derived from a master key stored in localStorage
+- Content expires after 30 days
+
+## Security Updates
+
+Security updates will be announced via:
+
+- Security advisories in issues
+- Release notes
+- Project documentation
+
+## Responsible Disclosure
+
+We appreciate responsible disclosure. If you follow these guidelines, we will:
+
+- Work with you to understand and resolve the issue quickly
+- Credit you in our security advisory (if desired)
+- Not take legal action against you
+
+## Security Checklist for Pull Requests
+
+Before submitting a PR, ensure:
+
+- [ ] No hardcoded secrets or credentials
+- [ ] Input validation is implemented
+- [ ] Output is properly encoded/escaped
+- [ ] Error messages don't leak sensitive information
+- [ ] Dependencies are up to date (`npm audit`)
+- [ ] No `console.log` statements with sensitive data
+- [ ] Authentication/authorization is properly implemented
+- [ ] HTTPS is used for all external requests
+
+## Additional Resources
+
+- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
+- [Node.js Security Best Practices](https://nodejs.org/en/docs/guides/security/)
+- [Next.js Security](https://nextjs.org/docs/app/building-your-application/configuring/security-headers)
+
+Thank you for helping keep zapwall4Science secure!
diff --git a/components/ArticleCard.tsx b/components/ArticleCard.tsx
index 1e70d9a..182b1fe 100644
--- a/components/ArticleCard.tsx
+++ b/components/ArticleCard.tsx
@@ -11,6 +11,20 @@ interface ArticleCardProps {
onUnlock?: (article: Article) => void
}
+function ArticleHeader({ article }: { article: Article }) {
+ return (
+
+
{article.title}
+
+ {t('publication.viewAuthor')}
+
+
+ )
+}
+
function ArticleMeta({
article,
error,
@@ -56,15 +70,7 @@ export function ArticleCard({ article, onUnlock }: ArticleCardProps) {
return (
-
-
{article.title}
-
- {t('publication.viewAuthor')}
-
-
+
void
-}) {
- const { profiles, loading } = useAuthorsProfiles(authors)
- const [isOpen, setIsOpen] = useState(false)
- const dropdownRef = useRef(null)
- const buttonRef = useRef(null)
-
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (
- dropdownRef.current &&
- buttonRef.current &&
- !dropdownRef.current.contains(event.target as Node) &&
- !buttonRef.current.contains(event.target as Node)
- ) {
- setIsOpen(false)
- }
- }
-
- const handleEscape = (event: KeyboardEvent) => {
- if (event.key === 'Escape') {
- setIsOpen(false)
- buttonRef.current?.focus()
- }
- }
-
- if (isOpen) {
- document.addEventListener('mousedown', handleClickOutside)
- document.addEventListener('keydown', handleEscape)
- }
-
- return () => {
- document.removeEventListener('mousedown', handleClickOutside)
- document.removeEventListener('keydown', handleEscape)
- }
- }, [isOpen])
-
- const getDisplayName = (pubkey: string): string => {
- const profile = profiles.get(pubkey)
- return profile?.name ?? `${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}`
- }
-
- const getPicture = (pubkey: string): string | undefined => {
- return profiles.get(pubkey)?.picture
- }
-
- const getMnemonicIcons = (pubkey: string): string[] => {
- return generateMnemonicIcons(pubkey)
- }
-
- const selectedAuthor = value ? profiles.get(value) : null
- const selectedDisplayName = value ? getDisplayName(value) : t('filters.author')
-
- return (
-
-
- {t('filters.author')}
-
-
-
setIsOpen(!isOpen)}
- className="w-full px-3 py-2 border border-neon-cyan/30 rounded-lg focus:ring-2 focus:ring-neon-cyan focus:border-neon-cyan bg-cyber-dark text-cyber-accent text-left flex items-center gap-2 hover:border-neon-cyan/50 transition-colors"
- aria-expanded={isOpen}
- aria-haspopup="listbox"
- >
- {value && selectedAuthor?.picture ? (
-
- ) : value ? (
-
-
- {selectedDisplayName.charAt(0).toUpperCase()}
-
-
- ) : null}
- {selectedDisplayName}
- {value && (
-
- {getMnemonicIcons(value).map((icon, idx) => (
-
- {icon}
-
- ))}
-
- )}
-
-
-
-
- {isOpen && (
-
-
{
- onChange(null)
- setIsOpen(false)
- }}
- className={`w-full px-3 py-2 text-left hover:bg-cyber-light flex items-center gap-2 transition-colors ${
- value === null ? 'bg-neon-cyan/20 border-l-2 border-neon-cyan' : ''
- }`}
- role="option"
- aria-selected={value === null}
- >
- {t('filters.author')}
-
- {loading ? (
-
{t('filters.loading')}
- ) : (
- authors.map((pubkey) => {
- const displayName = getDisplayName(pubkey)
- const picture = getPicture(pubkey)
- const mnemonicIcons = getMnemonicIcons(pubkey)
- const isSelected = value === pubkey
-
- return (
-
{
- onChange(pubkey)
- setIsOpen(false)
- }}
- className={`w-full px-3 py-2 text-left hover:bg-cyber-light flex items-center gap-2 transition-colors ${
- isSelected ? 'bg-neon-cyan/20 border-l-2 border-neon-cyan' : ''
- }`}
- role="option"
- aria-selected={isSelected}
- >
- {picture ? (
-
- ) : (
-
-
- {displayName.charAt(0).toUpperCase()}
-
-
- )}
- {displayName}
-
- {mnemonicIcons.map((icon, idx) => (
-
- {icon}
-
- ))}
-
-
- )
- })
- )}
-
- )}
-
-
- )
-}
function SortFilter({
value,
@@ -279,7 +92,7 @@ function SortFilter({
onChange(e.target.value as SortOption)}
+ onChange={(e: React.ChangeEvent) => onChange(e.target.value as SortOption)}
className="block w-full px-3 py-2 border border-neon-cyan/30 rounded-lg focus:ring-2 focus:ring-neon-cyan focus:border-neon-cyan bg-cyber-dark text-cyber-accent hover:border-neon-cyan/50 transition-colors"
>
{t('filters.sort.newest')}
diff --git a/components/AuthorFilter.tsx b/components/AuthorFilter.tsx
new file mode 100644
index 0000000..556f47e
--- /dev/null
+++ b/components/AuthorFilter.tsx
@@ -0,0 +1,90 @@
+import React from 'react'
+import { t } from '@/lib/i18n'
+import { useAuthorFilterProps } from './AuthorFilterHooks'
+import { AuthorFilterButtonWrapper } from './AuthorFilterButton'
+import { AuthorDropdown } from './AuthorFilterDropdown'
+
+function AuthorFilterLabel() {
+ return (
+
+ {t('filters.author')}
+
+ )
+}
+
+interface AuthorFilterContentProps {
+ authors: string[]
+ value: string | null
+ isOpen: boolean
+ loading: boolean
+ onChange: (value: string | null) => void
+ setIsOpen: (open: boolean) => void
+ dropdownRef: React.RefObject
+ buttonRef: React.RefObject
+ getDisplayName: (pubkey: string) => string
+ getPicture: (pubkey: string) => string | undefined
+ getMnemonicIcons: (pubkey: string) => string[]
+ selectedAuthor: { name?: string; picture?: string } | null
+ selectedDisplayName: string
+}
+
+function AuthorFilterContent(props: AuthorFilterContentProps) {
+ return (
+
+
+ {props.isOpen && (
+
+ )}
+
+ )
+}
+
+export function AuthorFilter({
+ authors,
+ value,
+ onChange,
+}: {
+ authors: string[]
+ value: string | null
+ onChange: (value: string | null) => void
+}) {
+ const props = useAuthorFilterProps(authors, value)
+
+ return (
+
+ )
+}
diff --git a/components/AuthorFilterButton.tsx b/components/AuthorFilterButton.tsx
new file mode 100644
index 0000000..9369d2a
--- /dev/null
+++ b/components/AuthorFilterButton.tsx
@@ -0,0 +1,121 @@
+import React from 'react'
+import { AuthorAvatar } from './AuthorFilterDropdown'
+
+export function AuthorMnemonicIcons({ value, getMnemonicIcons }: { value: string; getMnemonicIcons: (pubkey: string) => string[] }) {
+ return (
+
+ {getMnemonicIcons(value).map((icon, idx) => (
+
+ {icon}
+
+ ))}
+
+ )
+}
+
+export function AuthorFilterButtonContent({
+ value,
+ selectedAuthor,
+ selectedDisplayName,
+ getMnemonicIcons,
+}: {
+ value: string | null
+ selectedAuthor: { name?: string; picture?: string } | null | undefined
+ selectedDisplayName: string
+ getMnemonicIcons: (pubkey: string) => string[]
+}) {
+ return (
+ <>
+ {value && (
+
+ )}
+ {selectedDisplayName}
+ {value && }
+ >
+ )
+}
+
+export function DropdownArrowIcon({ isOpen }: { isOpen: boolean }) {
+ return (
+
+
+
+ )
+}
+
+export function AuthorFilterButton({
+ value,
+ selectedAuthor,
+ selectedDisplayName,
+ getMnemonicIcons,
+ isOpen,
+ setIsOpen,
+ buttonRef,
+}: {
+ value: string | null
+ selectedAuthor: { name?: string; picture?: string } | null | undefined
+ selectedDisplayName: string
+ getMnemonicIcons: (pubkey: string) => string[]
+ isOpen: boolean
+ setIsOpen: (open: boolean) => void
+ buttonRef: React.RefObject
+}) {
+ return (
+ setIsOpen(!isOpen)}
+ className="w-full px-3 py-2 border border-neon-cyan/30 rounded-lg focus:ring-2 focus:ring-neon-cyan focus:border-neon-cyan bg-cyber-dark text-cyber-accent text-left flex items-center gap-2 hover:border-neon-cyan/50 transition-colors"
+ aria-expanded={isOpen}
+ aria-haspopup="listbox"
+ >
+
+
+
+ )
+}
+
+export function AuthorFilterButtonWrapper({
+ value,
+ selectedAuthor,
+ selectedDisplayName,
+ getMnemonicIcons,
+ isOpen,
+ setIsOpen,
+ buttonRef,
+}: {
+ value: string | null
+ selectedAuthor: { name?: string; picture?: string } | null
+ selectedDisplayName: string
+ getMnemonicIcons: (pubkey: string) => string[]
+ isOpen: boolean
+ setIsOpen: (open: boolean) => void
+ buttonRef: React.RefObject
+}) {
+ return (
+
+ )
+}
diff --git a/components/AuthorFilterDropdown.tsx b/components/AuthorFilterDropdown.tsx
new file mode 100644
index 0000000..24dd3e3
--- /dev/null
+++ b/components/AuthorFilterDropdown.tsx
@@ -0,0 +1,224 @@
+import React from 'react'
+import Image from 'next/image'
+import { t } from '@/lib/i18n'
+
+export function AuthorAvatar({ picture, displayName }: { picture?: string; displayName: string }) {
+ if (picture !== undefined) {
+ return (
+
+ )
+ }
+ return (
+
+ {displayName.charAt(0).toUpperCase()}
+
+ )
+}
+
+export function AuthorOption({
+ displayName,
+ picture,
+ mnemonicIcons,
+ isSelected,
+ onSelect,
+}: {
+ displayName: string
+ picture?: string
+ mnemonicIcons: string[]
+ isSelected: boolean
+ onSelect: () => void
+}) {
+ return (
+
+
+ {displayName}
+
+ {mnemonicIcons.map((icon, idx) => (
+
+ {icon}
+
+ ))}
+
+
+ )
+}
+
+export function AllAuthorsOption({
+ value,
+ onChange,
+ setIsOpen,
+}: {
+ value: string | null
+ onChange: (value: string | null) => void
+ setIsOpen: (open: boolean) => void
+}) {
+ return (
+ {
+ onChange(null)
+ setIsOpen(false)
+ }}
+ className={`w-full px-3 py-2 text-left hover:bg-cyber-light flex items-center gap-2 transition-colors ${
+ value === null ? 'bg-neon-cyan/20 border-l-2 border-neon-cyan' : ''
+ }`}
+ role="option"
+ aria-selected={value === null}
+ >
+ {t('filters.author')}
+
+ )
+}
+
+export function createAuthorOptionProps(
+ pubkey: string,
+ value: string | null,
+ getDisplayName: (pubkey: string) => string,
+ getPicture: (pubkey: string) => string | undefined,
+ getMnemonicIcons: (pubkey: string) => string[],
+ onChange: (value: string | null) => void,
+ setIsOpen: (open: boolean) => void
+): {
+ displayName: string
+ mnemonicIcons: string[]
+ isSelected: boolean
+ onSelect: () => void
+ picture?: string
+} {
+ const pictureValue = getPicture(pubkey)
+ const optionProps: {
+ displayName: string
+ mnemonicIcons: string[]
+ isSelected: boolean
+ onSelect: () => void
+ picture?: string
+ } = {
+ displayName: getDisplayName(pubkey),
+ mnemonicIcons: getMnemonicIcons(pubkey),
+ isSelected: value === pubkey,
+ onSelect: () => {
+ onChange(pubkey)
+ setIsOpen(false)
+ },
+ }
+ if (pictureValue !== undefined) {
+ optionProps.picture = pictureValue
+ }
+ return optionProps
+}
+
+export function AuthorList({
+ authors,
+ value,
+ getDisplayName,
+ getPicture,
+ getMnemonicIcons,
+ onChange,
+ setIsOpen,
+}: {
+ authors: string[]
+ value: string | null
+ getDisplayName: (pubkey: string) => string
+ getPicture: (pubkey: string) => string | undefined
+ getMnemonicIcons: (pubkey: string) => string[]
+ onChange: (value: string | null) => void
+ setIsOpen: (open: boolean) => void
+}) {
+ return (
+ <>
+ {authors.map((pubkey) => (
+
+ ))}
+ >
+ )
+}
+
+export function AuthorDropdownContent({
+ authors,
+ value,
+ loading,
+ getDisplayName,
+ getPicture,
+ getMnemonicIcons,
+ onChange,
+ setIsOpen,
+}: {
+ authors: string[]
+ value: string | null
+ loading: boolean
+ getDisplayName: (pubkey: string) => string
+ getPicture: (pubkey: string) => string | undefined
+ getMnemonicIcons: (pubkey: string) => string[]
+ onChange: (value: string | null) => void
+ setIsOpen: (open: boolean) => void
+}) {
+ return loading ? (
+ {t('filters.loading')}
+ ) : (
+
+ )
+}
+
+export function AuthorDropdown({
+ authors,
+ value,
+ loading,
+ onChange,
+ setIsOpen,
+ getDisplayName,
+ getPicture,
+ getMnemonicIcons,
+}: {
+ authors: string[]
+ value: string | null
+ loading: boolean
+ onChange: (value: string | null) => void
+ setIsOpen: (open: boolean) => void
+ getDisplayName: (pubkey: string) => string
+ getPicture: (pubkey: string) => string | undefined
+ getMnemonicIcons: (pubkey: string) => string[]
+}) {
+ return (
+
+ )
+}
diff --git a/components/AuthorFilterHooks.tsx b/components/AuthorFilterHooks.tsx
new file mode 100644
index 0000000..428a7c6
--- /dev/null
+++ b/components/AuthorFilterHooks.tsx
@@ -0,0 +1,86 @@
+import { useState, useEffect, useRef } from 'react'
+import { useAuthorsProfiles } from '@/hooks/useAuthorsProfiles'
+import { generateMnemonicIcons } from '@/lib/mnemonicIcons'
+import { t } from '@/lib/i18n'
+
+export function useAuthorFilterDropdown(isOpen: boolean, setIsOpen: (open: boolean) => void) {
+ const dropdownRef = useRef(null)
+ const buttonRef = useRef(null)
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ dropdownRef.current &&
+ buttonRef.current &&
+ !dropdownRef.current.contains(event.target as Node) &&
+ !buttonRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false)
+ }
+ }
+
+ const handleEscape = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ setIsOpen(false)
+ buttonRef.current?.focus()
+ }
+ }
+
+ if (isOpen) {
+ document.addEventListener('mousedown', handleClickOutside)
+ document.addEventListener('keydown', handleEscape)
+ }
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside)
+ document.removeEventListener('keydown', handleEscape)
+ }
+ }, [isOpen, setIsOpen])
+
+ return { dropdownRef, buttonRef }
+}
+
+export function useAuthorFilterHelpers(profiles: Map) {
+ const getDisplayName = (pubkey: string): string => {
+ const profile = profiles.get(pubkey)
+ return profile?.name ?? `${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}`
+ }
+
+ const getPicture = (pubkey: string): string | undefined => {
+ return profiles.get(pubkey)?.picture
+ }
+
+ const getMnemonicIcons = (pubkey: string): string[] => {
+ return generateMnemonicIcons(pubkey)
+ }
+
+ return { getDisplayName, getPicture, getMnemonicIcons }
+}
+
+export function useAuthorFilterState(authors: string[], value: string | null) {
+ const { profiles, loading } = useAuthorsProfiles(authors)
+ const [isOpen, setIsOpen] = useState(false)
+ const { dropdownRef, buttonRef } = useAuthorFilterDropdown(isOpen, setIsOpen)
+ const { getDisplayName, getPicture, getMnemonicIcons } = useAuthorFilterHelpers(profiles)
+ const selectedAuthor = value ? (profiles.get(value) ?? null) : null
+ const selectedDisplayName = value ? getDisplayName(value) : t('filters.author')
+
+ return {
+ profiles,
+ loading,
+ isOpen,
+ setIsOpen,
+ dropdownRef,
+ buttonRef,
+ getDisplayName,
+ getPicture,
+ getMnemonicIcons,
+ selectedAuthor,
+ selectedDisplayName,
+ }
+}
+
+export function useAuthorFilterProps(authors: string[], value: string | null) {
+ const state = useAuthorFilterState(authors, value)
+ return { ...state, authors, value }
+}
diff --git a/components/AuthorPresentationEditor.tsx b/components/AuthorPresentationEditor.tsx
index d8e4b21..f97f4c7 100644
--- a/components/AuthorPresentationEditor.tsx
+++ b/components/AuthorPresentationEditor.tsx
@@ -3,7 +3,7 @@ import { useNostrAuth } from '@/hooks/useNostrAuth'
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { ArticleField } from './ArticleField'
import { ArticleFormButtons } from './ArticleFormButtons'
-import { ConnectButton } from './ConnectButton'
+import { CreateAccountModal } from './CreateAccountModal'
import { ImageUploadField } from './ImageUploadField'
import { PresentationFormHeader } from './PresentationFormHeader'
import { t } from '@/lib/i18n'
@@ -193,6 +193,32 @@ function useAuthorPresentationState(pubkey: string | null) {
return { loading, error, success, draft, setDraft, validationError, handleSubmit }
}
+function NoAccountView() {
+ const [showCreateModal, setShowCreateModal] = useState(false)
+
+ return (
+
+
+
+ Créez un compte ou importez votre clé secrète pour commencer
+
+
setShowCreateModal(true)}
+ className="px-6 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
+ >
+ Créer un compte ou importer une clé
+
+ {showCreateModal && (
+
setShowCreateModal(false)}
+ onClose={() => setShowCreateModal(false)}
+ />
+ )}
+
+
+ )
+}
+
function AuthorPresentationFormView({
pubkey,
connected,
@@ -205,13 +231,7 @@ function AuthorPresentationFormView({
const state = useAuthorPresentationState(pubkey)
if (!pubkey) {
- return (
-
- )
+ return
}
if (state.success) {
return
diff --git a/components/ConditionalPublishButton.tsx b/components/ConditionalPublishButton.tsx
index 9e5cb42..3f946ef 100644
--- a/components/ConditionalPublishButton.tsx
+++ b/components/ConditionalPublishButton.tsx
@@ -4,6 +4,32 @@ import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
import { useEffect, useState } from 'react'
import { t } from '@/lib/i18n'
+const buttonClassName = 'px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg text-sm font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan'
+
+function CreateAuthorPageLink() {
+ return (
+
+ {t('nav.createAuthorPage')}
+
+ )
+}
+
+function PublishLink() {
+ return (
+
+ {t('nav.publish')}
+
+ )
+}
+
+function LoadingButton() {
+ return (
+
+ {t('nav.loading')}
+
+ )
+}
+
export function ConditionalPublishButton() {
const { connected, pubkey } = useNostrAuth()
const { checkPresentationExists } = useAuthorPresentation(pubkey ?? null)
@@ -22,41 +48,16 @@ export function ConditionalPublishButton() {
}, [connected, pubkey, checkPresentationExists])
if (!connected || !pubkey) {
- return (
-
- {t('nav.createAuthorPage')}
-
- )
+ return
}
if (hasPresentation === null) {
- return (
-
- {t('nav.loading')}
-
- )
+ return
}
if (!hasPresentation) {
- return (
-
- {t('nav.createAuthorPage')}
-
- )
+ return
}
- return (
-
- {t('nav.publish')}
-
- )
+ return
}
diff --git a/components/ConnectButton.tsx b/components/ConnectButton.tsx
index b40d8dd..f62fe96 100644
--- a/components/ConnectButton.tsx
+++ b/components/ConnectButton.tsx
@@ -1,50 +1,172 @@
+import { useState, useEffect } from 'react'
import { useNostrAuth } from '@/hooks/useNostrAuth'
import { ConnectedUserMenu } from './ConnectedUserMenu'
+import { CreateAccountModal } from './CreateAccountModal'
+import { UnlockAccountModal } from './UnlockAccountModal'
+import type { NostrProfile } from '@/types/nostr'
-function ConnectForm({ onConnect, loading, error }: {
- onConnect: () => void
+function ConnectForm({
+ onCreateAccount,
+ onUnlock,
+ loading,
+ error,
+}: {
+ onCreateAccount: () => void
+ onUnlock: () => void
loading: boolean
error: string | null
}) {
return (
{
- void onConnect()
- }}
+ onClick={onCreateAccount}
disabled={loading}
className="px-6 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
>
- {loading ? 'Connecting...' : 'Connect with Nostr'}
+ Créer un compte
+
+
+ Se connecter
{error &&
{error}
}
)
}
-export function ConnectButton() {
- const { connected, pubkey, profile, loading, error, connect, disconnect } = useNostrAuth()
+function useAutoConnect(accountExists: boolean | null, pubkey: string | null, showCreateModal: boolean, showUnlockModal: boolean, connect: () => Promise) {
+ useEffect(() => {
+ if (accountExists === true && !pubkey && !showCreateModal && !showUnlockModal) {
+ void connect()
+ }
+ }, [accountExists, pubkey, showCreateModal, showUnlockModal, connect])
+}
- if (connected && pubkey) {
- return (
- {
- void disconnect()
- }}
+function ConnectedState({ pubkey, profile, loading, disconnect }: { pubkey: string; profile: NostrProfile | null; loading: boolean; disconnect: () => Promise }) {
+ return (
+ {
+ void disconnect()
+ }}
+ loading={loading}
+ />
+ )
+}
+
+function UnlockState({ loading, error, onUnlock, onClose }: { loading: boolean; error: string | null; onUnlock: () => void; onClose: () => void }) {
+ return (
+ <>
+ {}}
+ onUnlock={onUnlock}
loading={loading}
+ error={error}
+ />
+
+ >
+ )
+}
+
+function DisconnectedModals({
+ showCreateModal,
+ showUnlockModal,
+ setShowCreateModal,
+ setShowUnlockModal,
+}: {
+ showCreateModal: boolean
+ showUnlockModal: boolean
+ setShowCreateModal: (show: boolean) => void
+ setShowUnlockModal: (show: boolean) => void
+}) {
+ return (
+ <>
+ {showCreateModal && (
+ {
+ setShowCreateModal(false)
+ setShowUnlockModal(true)
+ }}
+ onClose={() => setShowCreateModal(false)}
+ />
+ )}
+ {showUnlockModal && (
+ setShowUnlockModal(false)}
+ onClose={() => setShowUnlockModal(false)}
+ />
+ )}
+ >
+ )
+}
+
+function DisconnectedState({
+ loading,
+ error,
+ showCreateModal,
+ showUnlockModal,
+ setShowCreateModal,
+ setShowUnlockModal,
+}: {
+ loading: boolean
+ error: string | null
+ showCreateModal: boolean
+ showUnlockModal: boolean
+ setShowCreateModal: (show: boolean) => void
+ setShowUnlockModal: (show: boolean) => void
+}) {
+ return (
+ <>
+ setShowCreateModal(true)}
+ onUnlock={() => setShowUnlockModal(true)}
+ loading={loading}
+ error={error}
+ />
+
+ >
+ )
+}
+
+export function ConnectButton() {
+ const { connected, pubkey, profile, loading, error, connect, disconnect, accountExists, isUnlocked } = useNostrAuth()
+ const [showCreateModal, setShowCreateModal] = useState(false)
+ const [showUnlockModal, setShowUnlockModal] = useState(false)
+
+ useAutoConnect(accountExists, pubkey, showCreateModal, showUnlockModal, connect)
+
+ if (connected && pubkey && isUnlocked) {
+ return
+ }
+
+ if (accountExists === true && pubkey && !isUnlocked && !showUnlockModal && !showCreateModal) {
+ return (
+ setShowUnlockModal(true)}
+ onClose={() => setShowUnlockModal(false)}
/>
)
}
return (
- {
- void connect()
- }}
+
)
}
diff --git a/components/CreateAccountModal.tsx b/components/CreateAccountModal.tsx
new file mode 100644
index 0000000..fcab9cd
--- /dev/null
+++ b/components/CreateAccountModal.tsx
@@ -0,0 +1,159 @@
+import { useState } from 'react'
+import { nostrAuthService } from '@/lib/nostrAuth'
+import { RecoveryStep, ImportStep, ChooseStep } from './CreateAccountModalSteps'
+
+interface CreateAccountModalProps {
+ onSuccess: (npub: string) => void
+ onClose: () => void
+}
+
+type Step = 'choose' | 'import' | 'recovery'
+
+async function createAccountWithKey(key?: string) {
+ return await nostrAuthService.createAccount(key)
+}
+
+async function handleAccountCreation(
+ key: string | undefined,
+ setLoading: (loading: boolean) => void,
+ setError: (error: string | null) => void,
+ setRecoveryPhrase: (phrase: string[]) => void,
+ setNpub: (npub: string) => void,
+ setStep: (step: Step) => void,
+ errorMessage: string
+) {
+ if (key !== undefined && !key.trim()) {
+ setError('Please enter a private key')
+ return
+ }
+
+ setLoading(true)
+ setError(null)
+ try {
+ const result = await createAccountWithKey(key?.trim())
+ setRecoveryPhrase(result.recoveryPhrase)
+ setNpub(result.npub)
+ setStep('recovery')
+ } catch (e) {
+ setError(e instanceof Error ? e.message : errorMessage)
+ } finally {
+ setLoading(false)
+ }
+}
+
+function useAccountCreation() {
+ const [step, setStep] = useState('choose')
+ const [importKey, setImportKey] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [recoveryPhrase, setRecoveryPhrase] = useState([])
+ const [npub, setNpub] = useState('')
+
+ const handleGenerate = async () => {
+ await handleAccountCreation(undefined, setLoading, setError, setRecoveryPhrase, setNpub, setStep, 'Failed to create account')
+ }
+
+ const handleImport = async () => {
+ await handleAccountCreation(importKey, setLoading, setError, setRecoveryPhrase, setNpub, setStep, 'Failed to import key')
+ }
+
+ return {
+ step,
+ setStep,
+ importKey,
+ setImportKey,
+ loading,
+ error,
+ setError,
+ recoveryPhrase,
+ npub,
+ handleGenerate,
+ handleImport,
+ }
+}
+
+function handleImportBack(setStep: (step: Step) => void, setError: (error: string | null) => void, setImportKey: (key: string) => void) {
+ setStep('choose')
+ setError(null)
+ setImportKey('')
+}
+
+function renderStep(
+ step: Step,
+ recoveryPhrase: string[],
+ npub: string,
+ importKey: string,
+ setImportKey: (key: string) => void,
+ loading: boolean,
+ error: string | null,
+ handleContinue: () => void,
+ handleImport: () => void,
+ setStep: (step: Step) => void,
+ setError: (error: string | null) => void,
+ handleGenerate: () => void,
+ onClose: () => void
+) {
+ if (step === 'recovery') {
+ return
+ }
+
+ if (step === 'import') {
+ return (
+ handleImportBack(setStep, setError, setImportKey)}
+ />
+ )
+ }
+
+ return (
+ setStep('import')}
+ onClose={onClose}
+ />
+ )
+}
+
+export function CreateAccountModal({ onSuccess, onClose }: CreateAccountModalProps) {
+ const {
+ step,
+ setStep,
+ importKey,
+ setImportKey,
+ loading,
+ error,
+ setError,
+ recoveryPhrase,
+ npub,
+ handleGenerate,
+ handleImport,
+ } = useAccountCreation()
+
+ const handleContinue = () => {
+ onSuccess(npub)
+ onClose()
+ }
+
+ return renderStep(
+ step,
+ recoveryPhrase,
+ npub,
+ importKey,
+ setImportKey,
+ loading,
+ error,
+ handleContinue,
+ handleImport,
+ setStep,
+ setError,
+ handleGenerate,
+ onClose
+ )
+}
diff --git a/components/CreateAccountModalComponents.tsx b/components/CreateAccountModalComponents.tsx
new file mode 100644
index 0000000..2ef7919
--- /dev/null
+++ b/components/CreateAccountModalComponents.tsx
@@ -0,0 +1,148 @@
+
+export function RecoveryWarning() {
+ return (
+
+
⚠️ Important
+
+ Ces 4 mots-clés sont votre seule façon de récupérer votre compte.
+ Ils ne seront jamais affichés à nouveau.
+
+
+ Notez-les dans un endroit sûr. Sans ces mots-clés, vous perdrez définitivement l'accès à votre compte.
+
+
+ )
+}
+
+export function RecoveryPhraseDisplay({
+ recoveryPhrase,
+ copied,
+ onCopy,
+}: {
+ recoveryPhrase: string[]
+ copied: boolean
+ onCopy: () => void
+}) {
+ return (
+
+
+ {recoveryPhrase.map((word, index) => (
+
+ {index + 1}.
+ {word}
+
+ ))}
+
+
{
+ void onCopy()
+ }}
+ className="w-full py-2 px-4 bg-gray-200 hover:bg-gray-300 rounded-lg text-sm font-medium transition-colors"
+ >
+ {copied ? '✓ Copié!' : 'Copier les mots-clés'}
+
+
+ )
+}
+
+export function PublicKeyDisplay({ npub }: { npub: string }) {
+ return (
+
+
Votre clé publique (npub)
+
{npub}
+
+ )
+}
+
+export function ImportKeyForm({
+ importKey,
+ setImportKey,
+ error,
+}: {
+ importKey: string
+ setImportKey: (key: string) => void
+ error: string | null
+}) {
+ return (
+ <>
+
+
+ Clé privée (nsec ou hex)
+
+
+ {error && {error}
}
+ >
+ )
+}
+
+export function ImportStepButtons({ loading, onImport, onBack }: { loading: boolean; onImport: () => void; onBack: () => void }) {
+ return (
+
+
+ Retour
+
+ {
+ void onImport()
+ }}
+ disabled={loading}
+ className="flex-1 py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
+ >
+ {loading ? 'Importation...' : 'Importer'}
+
+
+ )
+}
+
+export function ChooseStepButtons({
+ loading,
+ onGenerate,
+ onImport,
+ onClose,
+}: {
+ loading: boolean
+ onGenerate: () => void
+ onImport: () => void
+ onClose: () => void
+}) {
+ return (
+
+ {
+ void onGenerate()
+ }}
+ disabled={loading}
+ className="w-full py-3 px-6 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
+ >
+ {loading ? 'Génération...' : 'Générer un nouveau compte'}
+
+
+ Importer une clé existante
+
+
+ Annuler
+
+
+ )
+}
diff --git a/components/CreateAccountModalSteps.tsx b/components/CreateAccountModalSteps.tsx
new file mode 100644
index 0000000..7f5606f
--- /dev/null
+++ b/components/CreateAccountModalSteps.tsx
@@ -0,0 +1,94 @@
+import { useState } from 'react'
+import { RecoveryWarning, RecoveryPhraseDisplay, PublicKeyDisplay, ImportKeyForm, ImportStepButtons, ChooseStepButtons } from './CreateAccountModalComponents'
+
+export function RecoveryStep({
+ recoveryPhrase,
+ npub,
+ onContinue,
+}: {
+ recoveryPhrase: string[]
+ npub: string
+ onContinue: () => void
+}) {
+ const [copied, setCopied] = useState(false)
+
+ const handleCopy = async () => {
+ if (recoveryPhrase.length > 0) {
+ await navigator.clipboard.writeText(recoveryPhrase.join(' '))
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+ }
+
+ return (
+
+
+
Sauvegardez vos mots-clés de récupération
+
+
+
+
+
+ J'ai sauvegardé mes mots-clés
+
+
+
+
+ )
+}
+
+export function ImportStep({
+ importKey,
+ setImportKey,
+ loading,
+ error,
+ onImport,
+ onBack,
+}: {
+ importKey: string
+ setImportKey: (key: string) => void
+ loading: boolean
+ error: string | null
+ onImport: () => void
+ onBack: () => void
+}) {
+ return (
+
+
+
Importer une clé privée
+
+
+
+
+ )
+}
+
+export function ChooseStep({
+ loading,
+ error,
+ onGenerate,
+ onImport,
+ onClose,
+}: {
+ loading: boolean
+ error: string | null
+ onGenerate: () => void
+ onImport: () => void
+ onClose: () => void
+}) {
+ return (
+
+
+
Créer un compte
+
+ Créez un nouveau compte Nostr ou importez une clé privée existante.
+
+ {error &&
{error}
}
+
+
+
+ )
+}
diff --git a/components/FundingGauge.tsx b/components/FundingGauge.tsx
index 26177d1..d6477d8 100644
--- a/components/FundingGauge.tsx
+++ b/components/FundingGauge.tsx
@@ -2,6 +2,45 @@ import { useEffect, useState } from 'react'
import { estimatePlatformFunds } from '@/lib/fundingCalculation'
import { t } from '@/lib/i18n'
+interface FundingProgressBarProps {
+ progressPercent: number
+}
+
+function FundingProgressBar({ progressPercent }: FundingProgressBarProps) {
+ return (
+
+
+
+
+ {progressPercent.toFixed(1)}%
+
+
+
+ )
+}
+
+function FundingStats({ stats }: { stats: ReturnType }) {
+ const progressPercent = Math.min(100, stats.progressPercent)
+ return (
+
+
+ {t('home.funding.current', { current: stats.totalBTC.toFixed(6) })}
+ {t('home.funding.target', { target: stats.targetBTC.toFixed(2) })}
+
+
+
+ {t('home.funding.progress', { percent: progressPercent.toFixed(1) })}
+
+
+ {t('home.funding.description')}
+
+
+ )
+}
+
export function FundingGauge() {
const [stats, setStats] = useState(estimatePlatformFunds())
const [loading, setLoading] = useState(true)
@@ -30,38 +69,10 @@ export function FundingGauge() {
)
}
- const progressPercent = Math.min(100, stats.progressPercent)
-
return (
{t('home.funding.title')}
-
-
-
- {t('home.funding.current', { current: stats.totalBTC.toFixed(6) })}
- {t('home.funding.target', { target: stats.targetBTC.toFixed(2) })}
-
-
-
-
-
-
- {progressPercent.toFixed(1)}%
-
-
-
-
-
- {t('home.funding.progress', { percent: progressPercent.toFixed(1) })}
-
-
-
- {t('home.funding.description')}
-
-
+
)
}
diff --git a/components/HomeView.tsx b/components/HomeView.tsx
index e121822..e7f7109 100644
--- a/components/HomeView.tsx
+++ b/components/HomeView.tsx
@@ -59,10 +59,10 @@ function HomeIntroSection() {
- Consultez les auteurs et aperçus, achetez les parutions au fil de l'eau par 800 sats (moins 100 sats et frais de transaction).
+ Consultez les auteurs et aperçus, achetez les parutions au fil de l'eau par 800 sats (moins 100 sats et frais de transaction).
- Sponsorisez l'auteur pour 0.046 BTC (moins 0.004 BTC et frais de transaction).
+ Sponsorisez l'auteur pour 0.046 BTC (moins 0.004 BTC et frais de transaction).
Les avis sont remerciables pour 70 sats (moins 21 sats et frais de transaction).
diff --git a/components/ImageUploadField.tsx b/components/ImageUploadField.tsx
index 37e28ce..7c2e4b2 100644
--- a/components/ImageUploadField.tsx
+++ b/components/ImageUploadField.tsx
@@ -11,7 +11,87 @@ interface ImageUploadFieldProps {
helpText?: string | undefined
}
-export function ImageUploadField({ id, label, value, onChange, helpText }: ImageUploadFieldProps) {
+function ImagePreview({ value }: { value: string }) {
+ return (
+
+
+
+ )
+}
+
+function UploadButtonLabel({ uploading, value }: { uploading: boolean; value: string | undefined }) {
+ if (uploading) {
+ return <>{t('presentation.field.picture.uploading')}>
+ }
+ return <>{value ? t('presentation.field.picture.change') : t('presentation.field.picture.upload')}>
+}
+
+function RemoveButton({ value, onChange }: { value: string | undefined; onChange: (url: string) => void }) {
+ if (!value) {
+ return null
+ }
+ return (
+
onChange('')}
+ className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-all border border-red-500/50"
+ >
+ {t('presentation.field.picture.remove')}
+
+ )
+}
+
+function ImageUploadControls({
+ id,
+ uploading,
+ value,
+ onChange,
+ onFileSelect,
+}: {
+ id: string
+ uploading: boolean
+ value: string | undefined
+ onChange: (url: string) => void
+ onFileSelect: (e: React.ChangeEvent
) => Promise
+}) {
+ return (
+
+
+
+
+ {
+ void onFileSelect(e)
+ }}
+ disabled={uploading}
+ />
+
+
+ )
+}
+
+async function processFileUpload(file: File, onChange: (url: string) => void, setError: (error: string | null) => void) {
+ const media = await uploadNip95Media(file)
+ if (media.type === 'image') {
+ onChange(media.url)
+ } else {
+ setError(t('presentation.field.picture.error.imagesOnly'))
+ }
+}
+
+function useImageUpload(onChange: (url: string) => void) {
const [uploading, setUploading] = useState(false)
const [error, setError] = useState(null)
@@ -25,12 +105,7 @@ export function ImageUploadField({ id, label, value, onChange, helpText }: Image
setUploading(true)
try {
- const media = await uploadNip95Media(file)
- if (media.type === 'image') {
- onChange(media.url)
- } else {
- setError(t('presentation.field.picture.error.imagesOnly'))
- }
+ await processFileUpload(file, onChange, setError)
} catch (e) {
setError(e instanceof Error ? e.message : t('presentation.field.picture.error.uploadFailed'))
} finally {
@@ -38,6 +113,11 @@ export function ImageUploadField({ id, label, value, onChange, helpText }: Image
}
}
+ return { uploading, error, handleFileSelect }
+}
+
+export function ImageUploadField({ id, label, value, onChange, helpText }: ImageUploadFieldProps) {
+ const { uploading, error, handleFileSelect } = useImageUpload(onChange)
const displayLabel = label ?? t('presentation.field.picture')
const displayHelpText = helpText ?? t('presentation.field.picture.help')
@@ -46,47 +126,16 @@ export function ImageUploadField({ id, label, value, onChange, helpText }: Image
{displayLabel}
- {value && (
-
-
-
- )}
-
-
- {uploading ? t('presentation.field.picture.uploading') : value ? t('presentation.field.picture.change') : t('presentation.field.picture.upload')}
-
-
- {value && (
- onChange('')}
- className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-all border border-red-500/50"
- >
- {t('presentation.field.picture.remove')}
-
- )}
-
- {error && (
- {error}
- )}
- {displayHelpText && (
- {displayHelpText}
- )}
+ {value && }
+
+ {error && {error}
}
+ {displayHelpText && {displayHelpText}
}
)
}
diff --git a/components/LanguageSelector.tsx b/components/LanguageSelector.tsx
index 4fbda83..09912b8 100644
--- a/components/LanguageSelector.tsx
+++ b/components/LanguageSelector.tsx
@@ -3,6 +3,29 @@ import { setLocale, getLocale, type Locale } from '@/lib/i18n'
const LOCALE_STORAGE_KEY = 'zapwall-locale'
+interface LocaleButtonProps {
+ locale: Locale
+ label: string
+ currentLocale: Locale
+ onClick: (locale: Locale) => void
+}
+
+function LocaleButton({ locale, label, currentLocale, onClick }: LocaleButtonProps) {
+ const isActive = currentLocale === locale
+ return (
+
onClick(locale)}
+ className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
+ isActive
+ ? 'bg-neon-cyan/20 text-neon-cyan border border-neon-cyan/50'
+ : 'text-cyber-accent hover:text-neon-cyan border border-transparent hover:border-neon-cyan/30'
+ }`}
+ >
+ {label}
+
+ )
+}
+
export function LanguageSelector() {
const [currentLocale, setCurrentLocale] = useState
(getLocale())
@@ -27,26 +50,8 @@ export function LanguageSelector() {
return (
- handleLocaleChange('fr')}
- className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
- currentLocale === 'fr'
- ? 'bg-neon-cyan/20 text-neon-cyan border border-neon-cyan/50'
- : 'text-cyber-accent hover:text-neon-cyan border border-transparent hover:border-neon-cyan/30'
- }`}
- >
- FR
-
- handleLocaleChange('en')}
- className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
- currentLocale === 'en'
- ? 'bg-neon-cyan/20 text-neon-cyan border border-neon-cyan/50'
- : 'text-cyber-accent hover:text-neon-cyan border border-transparent hover:border-neon-cyan/30'
- }`}
- >
- EN
-
+
+
)
}
diff --git a/components/UnlockAccountModal.tsx b/components/UnlockAccountModal.tsx
new file mode 100644
index 0000000..76cdd95
--- /dev/null
+++ b/components/UnlockAccountModal.tsx
@@ -0,0 +1,159 @@
+import { useState } from 'react'
+import { nostrAuthService } from '@/lib/nostrAuth'
+
+interface UnlockAccountModalProps {
+ onSuccess: () => void
+ onClose: () => void
+}
+
+function WordInputs({
+ words,
+ onWordChange,
+}: {
+ words: string[]
+ onWordChange: (index: number, value: string) => void
+}) {
+ return (
+
+ {words.map((word, index) => (
+
+
+ Mot {index + 1}
+
+ onWordChange(index, e.target.value)}
+ className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-lg text-center"
+ autoComplete="off"
+ autoCapitalize="off"
+ autoCorrect="off"
+ spellCheck="false"
+ />
+
+ ))}
+
+ )
+}
+
+function useUnlockAccount(words: string[], setWords: (words: string[]) => void, setError: (error: string | null) => void) {
+ const handleWordChange = (index: number, value: string) => {
+ const newWords = [...words]
+ newWords[index] = value.trim().toLowerCase()
+ setWords(newWords)
+ setError(null)
+ }
+
+ const handlePaste = async () => {
+ try {
+ const text = await navigator.clipboard.readText()
+ const pastedWords = text.trim().split(/\s+/).slice(0, 4)
+ if (pastedWords.length === 4) {
+ setWords(pastedWords.map((w) => w.toLowerCase()))
+ setError(null)
+ }
+ } catch (_e) {
+ // Ignore clipboard errors
+ }
+ }
+
+ return { handleWordChange, handlePaste }
+}
+
+function UnlockAccountButtons({
+ loading,
+ words,
+ onUnlock,
+ onClose,
+}: {
+ loading: boolean
+ words: string[]
+ onUnlock: () => void
+ onClose: () => void
+}) {
+ return (
+
+
+ Annuler
+
+ {
+ void onUnlock()
+ }}
+ disabled={loading || words.some((word) => !word)}
+ className="flex-1 py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
+ >
+ {loading ? 'Déverrouillage...' : 'Déverrouiller'}
+
+
+ )
+}
+
+function UnlockAccountForm({
+ words,
+ handleWordChange,
+ handlePaste,
+}: {
+ words: string[]
+ handleWordChange: (index: number, value: string) => void
+ handlePaste: () => void
+}) {
+ return (
+
+
+ {
+ void handlePaste()
+ }}
+ className="mt-2 text-sm text-gray-600 hover:text-gray-800 underline"
+ >
+ Coller depuis le presse-papiers
+
+
+ )
+}
+
+export function UnlockAccountModal({ onSuccess, onClose }: UnlockAccountModalProps) {
+ const [words, setWords] = useState(['', '', '', ''])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ const { handleWordChange, handlePaste } = useUnlockAccount(words, setWords, setError)
+
+ const handleUnlock = async () => {
+ if (words.some((word) => !word)) {
+ setError('Veuillez remplir tous les mots-clés')
+ return
+ }
+
+ setLoading(true)
+ setError(null)
+ try {
+ await nostrAuthService.unlockAccount(words)
+ onSuccess()
+ onClose()
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Échec du déverrouillage. Vérifiez vos mots-clés.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+
Déverrouiller votre compte
+
+ Entrez vos 4 mots-clés de récupération pour déverrouiller votre compte.
+
+
+ {error &&
{error}
}
+
+
+
+ )
+}
diff --git a/deploy.sh b/deploy.sh
new file mode 100644
index 0000000..d0df006
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,204 @@
+#!/bin/bash
+set -e
+
+# Configuration
+SERVER="debian@92.243.27.35"
+APP_NAME="zapwall"
+DOMAIN="zapwall.fr"
+APP_DIR="/var/www/${DOMAIN}"
+GIT_REPO="https://git.4nkweb.com/4nk/story-research-zapwall.git"
+
+# Vérifier qu'un message de commit est fourni
+if [ -z "$1" ]; then
+ echo "Erreur: Un message de commit est requis"
+ echo ""
+ echo "Usage: ./deploy.sh \"Message de commit\""
+ echo ""
+ echo "Exemple: ./deploy.sh \"Fix: Correction du bug de connexion\""
+ exit 1
+fi
+
+COMMIT_MESSAGE="$1"
+
+echo "=== Déploiement de ${DOMAIN} ==="
+echo ""
+
+# Détecter la branche courante
+BRANCH=$(git branch --show-current 2>/dev/null || echo "main")
+echo "Branche courante: ${BRANCH}"
+echo "Message de commit: ${COMMIT_MESSAGE}"
+echo ""
+
+# Commit et push des modifications locales
+echo "1. Préparation du commit local..."
+HAS_CHANGES=false
+HAS_UNPUSHED_COMMITS=false
+
+# Vérifier s'il y a des modifications non commitées
+if ! git diff --quiet || ! git diff --cached --quiet; then
+ HAS_CHANGES=true
+fi
+
+# Vérifier s'il y a des commits non poussés
+if git rev-parse --abbrev-ref ${BRANCH}@{upstream} >/dev/null 2>&1; then
+ LOCAL=$(git rev-parse @)
+ REMOTE=$(git rev-parse @{u})
+ if [ "$LOCAL" != "$REMOTE" ]; then
+ HAS_UNPUSHED_COMMITS=true
+ fi
+else
+ # Pas de branche distante configurée, vérifier s'il y a des commits locaux
+ if git rev-list --count origin/${BRANCH}..HEAD >/dev/null 2>&1; then
+ UNPUSHED_COUNT=$(git rev-list --count origin/${BRANCH}..HEAD 2>/dev/null || echo "0")
+ if [ "$UNPUSHED_COUNT" -gt 0 ]; then
+ HAS_UNPUSHED_COMMITS=true
+ fi
+ fi
+fi
+
+if [ "$HAS_CHANGES" = true ]; then
+ echo " ✓ Modifications détectées"
+ echo ""
+ echo "2. Ajout des modifications..."
+ git add -A
+ echo " ✓ Fichiers ajoutés"
+ echo ""
+ echo "3. Création du commit..."
+ git commit -m "${COMMIT_MESSAGE}"
+ echo " ✓ Commit créé"
+ HAS_UNPUSHED_COMMITS=true
+fi
+
+if [ "$HAS_UNPUSHED_COMMITS" = true ]; then
+ echo ""
+ echo "4. Push vers le dépôt distant..."
+ git push origin ${BRANCH}
+ echo " ✓ Push effectué"
+else
+ echo " ⚠ Aucune modification à commiter ni commit à pousser"
+fi
+
+# Vérifier si Git est initialisé sur le serveur
+echo ""
+echo "5. Vérification du dépôt Git sur le serveur..."
+if ssh ${SERVER} "cd ${APP_DIR} && git status >/dev/null 2>&1"; then
+ echo " ✓ Dépôt Git détecté"
+else
+ echo " ⚠ Dépôt Git non initialisé, initialisation..."
+ ssh ${SERVER} "cd ${APP_DIR} && git init && git remote add origin ${GIT_REPO} 2>/dev/null || git remote set-url origin ${GIT_REPO}"
+ ssh ${SERVER} "cd ${APP_DIR} && git checkout -b ${BRANCH} 2>/dev/null || true"
+fi
+
+# Récupérer les dernières modifications
+echo ""
+echo "6. Récupération des dernières modifications..."
+ssh ${SERVER} "cd ${APP_DIR} && git fetch origin"
+
+# Sauvegarder les modifications locales sur le serveur
+echo ""
+echo "7. Sauvegarde des modifications locales sur le serveur..."
+STASH_OUTPUT=$(ssh ${SERVER} "cd ${APP_DIR} && git stash push -u -m 'Auto-stash before deploy - $(date +%Y-%m-%d_%H:%M:%S)' 2>&1" || echo "No changes to stash")
+if echo "$STASH_OUTPUT" | grep -q "No local changes"; then
+ echo " ✓ Aucune modification locale à sauvegarder"
+else
+ echo " ✓ Modifications locales sauvegardées"
+fi
+
+# Nettoyer les fichiers non suivis
+echo ""
+echo "8. Nettoyage des fichiers non suivis..."
+ssh ${SERVER} "cd ${APP_DIR} && git clean -fd || true"
+
+# Vérifier que la branche existe
+echo ""
+echo "9. Vérification de la branche ${BRANCH}..."
+if ssh ${SERVER} "cd ${APP_DIR} && git ls-remote --heads origin ${BRANCH} | grep -q ${BRANCH}"; then
+ echo " ✓ Branche ${BRANCH} trouvée"
+else
+ echo " ✗ Branche ${BRANCH} non trouvée sur le dépôt distant"
+ echo ""
+ echo " Branches disponibles:"
+ AVAILABLE_BRANCHES=$(ssh ${SERVER} "cd ${APP_DIR} && git ls-remote --heads origin | sed 's/.*refs\\/heads\\///'")
+ echo "$AVAILABLE_BRANCHES" | sed 's/^/ - /'
+ echo ""
+ echo " Erreur: La branche '${BRANCH}' n'existe pas sur le dépôt distant."
+ echo " Vérifiez que vous avez bien poussé la branche avec 'git push origin ${BRANCH}'"
+ exit 1
+fi
+
+# Mise à jour depuis la branche
+echo ""
+echo "10. Mise à jour depuis la branche ${BRANCH}..."
+ssh ${SERVER} "cd ${APP_DIR} && git checkout ${BRANCH} 2>/dev/null || git checkout -b ${BRANCH} origin/${BRANCH}"
+ssh ${SERVER} "cd ${APP_DIR} && git pull origin ${BRANCH}"
+
+# Afficher le dernier commit
+echo ""
+echo "11. Dernier commit:"
+ssh ${SERVER} "cd ${APP_DIR} && git log -1 --oneline"
+
+# Copier next.config.js local vers le serveur (pour ignorer ESLint pendant le build)
+echo ""
+echo "12. Mise à jour de next.config.js pour ignorer ESLint pendant le build..."
+if [ -f "next.config.js" ]; then
+ cat next.config.js | ssh ${SERVER} "cat > ${APP_DIR}/next.config.js"
+ echo " ✓ next.config.js mis à jour"
+else
+ echo " ⚠ next.config.js local non trouvé, utilisation de celui du serveur"
+fi
+
+# Installer les dépendances
+echo ""
+echo "13. Installation des dépendances..."
+ssh ${SERVER} "cd ${APP_DIR} && npm ci"
+
+# Construire l'application
+echo ""
+echo "14. Construction de l'application..."
+ssh ${SERVER} "cd ${APP_DIR} && npm run build"
+
+# Redémarrer le service
+echo ""
+echo "15. Redémarrage du service ${APP_NAME}..."
+ssh ${SERVER} "sudo systemctl restart ${APP_NAME}"
+sleep 3
+
+# Vérifier que le service fonctionne
+echo ""
+echo "16. Vérification du service..."
+if ssh ${SERVER} "sudo systemctl is-active ${APP_NAME} >/dev/null"; then
+ echo " ✓ Service actif"
+ echo ""
+ echo " Statut du service:"
+ ssh ${SERVER} "sudo systemctl status ${APP_NAME} --no-pager | head -10"
+else
+ echo " ✗ Service inactif, vérification des logs..."
+ ssh ${SERVER} "sudo journalctl -u ${APP_NAME} --no-pager -n 30"
+ exit 1
+fi
+
+# Vérifier que le port est en écoute
+echo ""
+echo "17. Vérification du port 3001..."
+if ssh ${SERVER} "sudo ss -tuln | grep -q ':3001 '"; then
+ echo " ✓ Port 3001 en écoute"
+else
+ echo " ⚠ Port 3001 non encore en écoute, attente..."
+ sleep 5
+ if ssh ${SERVER} "sudo ss -tuln | grep -q ':3001 '"; then
+ echo " ✓ Port 3001 maintenant en écoute"
+ else
+ echo " ✗ Port 3001 toujours non en écoute"
+ exit 1
+ fi
+fi
+
+echo ""
+echo "=== Déploiement terminé avec succès ==="
+echo ""
+echo "Site disponible sur: https://${DOMAIN}/"
+echo ""
+echo "Commandes utiles:"
+echo " Voir les logs: ssh ${SERVER} 'sudo journalctl -u ${APP_NAME} -f'"
+echo " Voir les stashes: ssh ${SERVER} 'cd ${APP_DIR} && git stash list'"
+echo " Restaurer un stash: ssh ${SERVER} 'cd ${APP_DIR} && git stash pop'"
diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md
new file mode 100644
index 0000000..56f178a
--- /dev/null
+++ b/docs/DOCUMENTATION.md
@@ -0,0 +1,133 @@
+# Documentation complète - zapwall.fr
+
+## 📚 Index de la documentation
+
+### 🚀 Déploiement et infrastructure
+
+#### Documentation principale
+
+1. **[Documentation complète du déploiement](docs/deployment.md)**
+ - Vue d'ensemble de l'architecture
+ - Configuration initiale
+ - Mise à jour du site (Git, transfert manuel)
+ - Configuration HTTPS (auto-signé et Let's Encrypt)
+ - Scripts disponibles
+ - Dépannage complet
+ - Maintenance et commandes utiles
+
+2. **[Référence des scripts](docs/scripts-reference.md)**
+ - Liste complète de tous les scripts
+ - Description détaillée de chaque script
+ - Paramètres et options
+ - Ordre d'exécution recommandé
+
+3. **[Guide de référence rapide](docs/quick-reference.md)**
+ - Commandes essentielles en un coup d'œil
+ - Informations importantes
+ - Liens rapides vers la documentation
+
+#### Guides pratiques
+
+4. **[README-DEPLOYMENT.md](README-DEPLOYMENT.md)**
+ - Guide de déploiement et mise à jour
+ - Méthodes de mise à jour
+ - Commandes utiles
+ - Configuration HTTPS
+
+5. **[RESUME-DEPLOIEMENT.md](RESUME-DEPLOIEMENT.md)**
+ - Résumé du déploiement
+ - État actuel
+ - Problèmes identifiés et solutions
+ - Prochaines étapes
+
+### 📝 Scripts de déploiement
+
+#### Scripts principaux
+
+- **`deploy.sh`** : Déploiement initial complet avec vérifications
+- **`update-remote-git.sh`** : Mise à jour via Git (stash + pull + rebuild) ⭐ **Recommandé**
+- **`update-from-git.sh`** : Mise à jour depuis dépôt local
+- **`finish-deploy.sh`** : Finalisation du déploiement
+
+#### Scripts de vérification
+
+- **`check-deploy.sh`** : Vérification préalable avant déploiement
+- **`check-deployment-status.sh`** : État complet du déploiement
+- **`check-nginx-config.sh`** : Vérification de la configuration nginx
+- **`check-git-repo.sh`** : Vérification du dépôt Git
+- **`final-status.sh`** : Résumé de l'état final
+
+#### Scripts de configuration
+
+- **`setup-https-autosigned.sh`** : Configuration HTTPS avec certificats auto-signés
+- **`deploy-letsencrypt.sh`** : Déploiement des certificats Let's Encrypt
+- **`open-firewall-ports.sh`** : Ouverture des ports 80/443
+- **`fix-nginx-config.sh`** : Correction de la configuration
+
+### 🔧 Informations techniques
+
+#### Serveur
+
+- **Adresse** : `92.243.27.35`
+- **Utilisateur** : `debian`
+- **Domaine** : `zapwall.fr`
+- **Répertoire** : `/var/www/zapwall.fr`
+- **Port application** : `3001`
+- **Service** : `zapwall.service` (systemd)
+- **Nginx** : Conteneur Docker `lecoffre_nginx_test`
+
+#### Architecture
+
+```
+Internet → Firewall (80/443) → Nginx Docker → Port 3001 → Next.js App
+```
+
+### 📖 Documentation utilisateur
+
+- **[Guide utilisateur](docs/user-guide.md)** : Guide d'utilisation de la plateforme
+- **[FAQ](docs/faq.md)** : Questions fréquentes
+- **[Guide de publication](docs/publishing-guide.md)** : Comment publier un article
+- **[Guide de paiement](docs/payment-guide.md)** : Comment effectuer un paiement
+
+### 🔬 Documentation technique
+
+- **[Documentation technique](docs/technical.md)** : Architecture technique
+- **[Configuration stricte](docs/STRICT_CONFIG_SUMMARY.md)** : Règles de qualité du code
+- **[Configuration Rizful API](docs/rizful-api-setup.md)** : Configuration de l'API Rizful
+
+### 📋 Spécifications
+
+- **[Fonctionnalités](features/features.md)** : Liste des fonctionnalités
+- **[Notifications](features/notifications-implementation.md)** : Implémentation des notifications
+- **[Séries et médias](features/series-and-media-spec.md)** : Spécification des séries
+- **[Refactoring](features/zapwall4science-refactoring.md)** : Notes de refactoring
+
+## 🎯 Démarrage rapide
+
+### Pour déployer ou mettre à jour
+
+```bash
+# Déploiement depuis la branche main (par défaut)
+./deploy.sh
+
+# Déploiement depuis une autre branche
+./deploy.sh develop
+```
+
+Le script `deploy.sh` effectue automatiquement :
+- Mise à jour depuis Git
+- Installation des dépendances
+- Construction de l'application
+- Redémarrage du service
+
+## 📞 Support
+
+En cas de problème :
+
+1. Consulter [docs/deployment.md - Section Dépannage](docs/deployment.md#dépannage)
+2. Vérifier les logs : `ssh debian@92.243.27.35 'sudo journalctl -u zapwall -n 100'`
+3. Utiliser les scripts de vérification
+
+---
+
+*Dernière mise à jour : 2025-12-28*
diff --git a/docs/README-DEPLOYMENT.md b/docs/README-DEPLOYMENT.md
new file mode 100644
index 0000000..096d59e
--- /dev/null
+++ b/docs/README-DEPLOYMENT.md
@@ -0,0 +1,200 @@
+# Guide de déploiement et mise à jour - zapwall.fr
+
+## État actuel
+
+- **Service**: zapwall.service (systemd)
+- **Répertoire**: `/var/www/zapwall.fr`
+- **Port application**: 3001
+- **Nginx**: Conteneur Docker `lecoffre_nginx_test`
+- **HTTPS**: Configuré avec redirection automatique HTTP → HTTPS
+
+## Mise à jour du site depuis Git
+
+### Méthode 1 : Si le dépôt Git est déjà cloné sur le serveur
+
+```bash
+# Se connecter au serveur
+ssh debian@92.243.27.35
+
+# Aller dans le répertoire de l'application
+cd /var/www/zapwall.fr
+
+# Récupérer les dernières modifications
+git fetch origin
+
+# Basculer sur la branche souhaitée (par défaut: main)
+git checkout main # ou master, ou une autre branche
+
+# Récupérer les modifications
+git pull origin main
+
+# Installer les dépendances
+npm ci
+
+# Construire l'application
+npm run build
+
+# Redémarrer le service
+sudo systemctl restart zapwall
+
+# Vérifier que le service fonctionne
+sudo systemctl status zapwall
+```
+
+### Méthode 2 : Utiliser le script de mise à jour
+
+Depuis votre machine locale :
+
+```bash
+# Mise à jour depuis la branche main
+./update-from-git.sh main
+
+# Ou depuis une autre branche
+./update-from-git.sh master
+```
+
+Le script :
+1. Se connecte au serveur
+2. Récupère les modifications depuis Git
+3. Installe les dépendances
+4. Construit l'application
+5. Redémarre le service
+6. Vérifie que tout fonctionne
+
+### Méthode 3 : Transfert manuel depuis le dépôt local
+
+Si le dépôt Git n'est pas sur le serveur :
+
+```bash
+# Depuis votre machine locale, dans le répertoire du projet
+tar --exclude='node_modules' \
+ --exclude='.next' \
+ --exclude='.git' \
+ --exclude='*.tsbuildinfo' \
+ --exclude='.env*.local' \
+ --exclude='.cursor' \
+ -czf - . | ssh debian@92.243.27.35 "cd /var/www/zapwall.fr && tar -xzf -"
+
+# Puis sur le serveur
+ssh debian@92.243.27.35
+cd /var/www/zapwall.fr
+npm ci
+npm run build
+sudo systemctl restart zapwall
+```
+
+## Commandes utiles
+
+### Voir les logs du service
+```bash
+ssh debian@92.243.27.35 'sudo journalctl -u zapwall -f'
+```
+
+### Vérifier le statut du service
+```bash
+ssh debian@92.243.27.35 'sudo systemctl status zapwall'
+```
+
+### Redémarrer le service
+```bash
+ssh debian@92.243.27.35 'sudo systemctl restart zapwall'
+```
+
+### Vérifier que le port 3001 est en écoute
+```bash
+ssh debian@92.243.27.35 'sudo ss -tuln | grep 3001'
+```
+
+### Vérifier la configuration nginx
+```bash
+ssh debian@92.243.27.35 'sudo docker exec lecoffre_nginx_test nginx -t'
+```
+
+### Recharger nginx après modification
+```bash
+ssh debian@92.243.27.35 'sudo docker exec lecoffre_nginx_test nginx -s reload'
+```
+
+## Configuration HTTPS
+
+Actuellement, HTTPS est configuré avec des certificats auto-signés. Pour obtenir des certificats Let's Encrypt valides :
+
+### Option 1 : Utiliser certbot via snap (recommandé)
+
+```bash
+ssh debian@92.243.27.35
+sudo snap install certbot --classic
+sudo docker stop lecoffre_nginx_test
+sudo certbot certonly --standalone -d zapwall.fr --non-interactive --agree-tos --email admin@zapwall.fr
+sudo docker start lecoffre_nginx_test
+
+# Copier les certificats dans le conteneur
+sudo docker cp /etc/letsencrypt/live/zapwall.fr/fullchain.pem lecoffre_nginx_test:/etc/letsencrypt/live/zapwall.fr/fullchain.pem
+sudo docker cp /etc/letsencrypt/live/zapwall.fr/privkey.pem lecoffre_nginx_test:/etc/letsencrypt/live/zapwall.fr/privkey.pem
+
+# Mettre à jour la configuration nginx pour utiliser les certificats Let's Encrypt
+# (modifier ssl_certificate et ssl_certificate_key dans /etc/nginx/conf.d/zapwall.fr.conf)
+sudo docker exec lecoffre_nginx_test nginx -s reload
+```
+
+### Option 2 : Utiliser acme.sh
+
+```bash
+ssh debian@92.243.27.35
+curl https://get.acme.sh | sh
+~/.acme.sh/acme.sh --issue -d zapwall.fr --standalone
+```
+
+## Structure des fichiers
+
+```
+/var/www/zapwall.fr/ # Répertoire de l'application
+├── .next/ # Build de production Next.js
+├── node_modules/ # Dépendances npm
+├── pages/ # Pages Next.js
+├── components/ # Composants React
+├── lib/ # Bibliothèques
+└── package.json # Configuration npm
+
+/etc/systemd/system/zapwall.service # Service systemd
+/etc/nginx/conf.d/zapwall.fr.conf # Configuration nginx (dans le conteneur)
+```
+
+## Dépannage
+
+### Le service ne démarre pas
+```bash
+# Voir les logs
+ssh debian@92.243.27.35 'sudo journalctl -u zapwall -n 50'
+
+# Vérifier que le répertoire existe
+ssh debian@92.243.27.35 'ls -la /var/www/zapwall.fr'
+
+# Vérifier que l'application est construite
+ssh debian@92.243.27.35 'ls -la /var/www/zapwall.fr/.next'
+```
+
+### Le port 3001 n'est pas en écoute
+```bash
+# Vérifier que le service est actif
+ssh debian@92.243.27.35 'sudo systemctl status zapwall'
+
+# Redémarrer le service
+ssh debian@92.243.27.35 'sudo systemctl restart zapwall'
+```
+
+### Nginx ne sert pas le bon site
+```bash
+# Vérifier la configuration
+ssh debian@92.243.35 'sudo docker exec lecoffre_nginx_test cat /etc/nginx/conf.d/zapwall.fr.conf'
+
+# Vérifier que proxy_pass pointe vers 172.17.0.1:3001
+# Vérifier que server_name contient zapwall.fr
+```
+
+## Notes importantes
+
+- Le service zapwall doit être actif pour que l'application soit accessible
+- Nginx fait un reverse proxy vers le port 3001
+- Les modifications de code nécessitent un rebuild (`npm run build`) et un redémarrage du service
+- Les certificats Let's Encrypt doivent être renouvelés tous les 90 jours
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..d2ce6ad
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,75 @@
+# Documentation du projet zapwall.fr
+
+## 📚 Documentation disponible
+
+### Déploiement et infrastructure
+
+- **[Documentation complète du déploiement](deployment.md)**
+ - Architecture du déploiement
+ - Configuration initiale
+ - Mise à jour du site
+ - Configuration HTTPS
+ - Dépannage
+ - Maintenance
+
+- **[Référence des scripts](scripts-reference.md)**
+ - Description de tous les scripts
+ - Utilisation et paramètres
+ - Ordre d'exécution recommandé
+
+- **[Guide de référence rapide](quick-reference.md)**
+ - Commandes essentielles
+ - Informations importantes
+ - Liens rapides
+
+### Documentation utilisateur
+
+- **[Guide utilisateur](../docs/user-guide.md)** : Guide d'utilisation de la plateforme
+- **[FAQ](../docs/faq.md)** : Questions fréquentes
+- **[Guide de publication](../docs/publishing-guide.md)** : Comment publier un article
+- **[Guide de paiement](../docs/payment-guide.md)** : Comment effectuer un paiement
+
+### Documentation technique
+
+- **[Documentation technique](../docs/technical.md)** : Architecture technique
+- **[Configuration stricte](../docs/STRICT_CONFIG_SUMMARY.md)** : Règles de qualité du code
+- **[Configuration Rizful API](../docs/rizful-api-setup.md)** : Configuration de l'API Rizful
+
+### Spécifications
+
+- **[Fonctionnalités](../features/features.md)** : Liste des fonctionnalités
+- **[Notifications](../features/notifications-implementation.md)** : Implémentation des notifications
+- **[Séries et médias](../features/series-and-media-spec.md)** : Spécification des séries
+- **[Refactoring](../features/zapwall4science-refactoring.md)** : Notes de refactoring
+
+## 🚀 Démarrage rapide
+
+### Développement local
+
+```bash
+npm install
+npm run dev
+```
+
+### Déploiement
+
+```bash
+# Vérification préalable
+./check-deploy.sh
+
+# Déploiement initial
+./deploy.sh
+
+# Mise à jour
+./update-remote-git.sh
+```
+
+## 📖 Navigation
+
+- Pour le **déploiement** : Commencez par [deployment.md](deployment.md)
+- Pour les **scripts** : Consultez [scripts-reference.md](scripts-reference.md)
+- Pour les **commandes rapides** : Voir [quick-reference.md](quick-reference.md)
+
+---
+
+*Dernière mise à jour : 2025-12-28*
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 0000000..b20f64f
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,611 @@
+# Documentation complète du déploiement - zapwall.fr
+
+## 📋 Table des matières
+
+1. [Vue d'ensemble](#vue-densemble)
+2. [Architecture du déploiement](#architecture-du-déploiement)
+3. [Configuration initiale](#configuration-initiale)
+4. [Mise à jour du site](#mise-à-jour-du-site)
+5. [Configuration HTTPS](#configuration-https)
+6. [Scripts disponibles](#scripts-disponibles)
+7. [Dépannage](#dépannage)
+8. [Maintenance](#maintenance)
+
+---
+
+## Vue d'ensemble
+
+### Informations du serveur
+
+- **Serveur** : ``
+- **Utilisateur** : ``
+- **Domaine** : `zapwall.fr`
+- **Répertoire de l'application** : `/var/www/zapwall.fr`
+- **Port application** : `3001`
+- **Service systemd** : `zapwall.service`
+- **Nginx** : Conteneur Docker `lecoffre_nginx_test`
+
+### État actuel
+
+- ✅ Application déployée et fonctionnelle
+- ✅ Service systemd actif
+- ✅ HTTPS configuré avec redirection automatique
+- ✅ Firewall configuré (ports 80/443 ouverts)
+- ✅ Certificats Let's Encrypt configurés (valides jusqu'au 2026-03-28)
+
+---
+
+## Architecture du déploiement
+
+### Schéma d'architecture
+
+```
+Internet
+ │
+ ▼
+[Port 80/443] ← Firewall (UFW)
+ │
+ ▼
+[Nginx Docker Container] lecoffre_nginx_test
+ │ (reverse proxy)
+ ▼
+[Port 3001] ← Application Next.js
+ │
+ ▼
+[zapwall.service] (systemd)
+ │
+ ▼
+[/var/www/zapwall.fr] (répertoire de l'application)
+```
+
+### Composants
+
+1. **Nginx (Docker)** : Reverse proxy gérant HTTP/HTTPS
+ - Conteneur : `lecoffre_nginx_test`
+ - Configuration : `/etc/nginx/conf.d/zapwall.fr.conf` (dans le conteneur)
+ - Ports : 80 (HTTP), 443 (HTTPS)
+
+2. **Application Next.js** : Application principale
+ - Service : `zapwall.service` (systemd)
+ - Port : 3001
+ - Répertoire : `/var/www/zapwall.fr`
+
+3. **Git** : Dépôt source
+ - URL : `https://git.4nkweb.com/4nk/story-research-zapwall.git`
+ - Branche par défaut : `main`
+
+---
+
+## Configuration initiale
+
+### Prérequis
+
+- Accès SSH au serveur
+- Docker installé et configuré
+- Node.js et npm installés
+- Git installé
+
+### Structure des fichiers sur le serveur
+
+```
+/var/www/zapwall.fr/ # Répertoire de l'application
+├── .next/ # Build de production Next.js
+├── .git/ # Dépôt Git (si initialisé)
+├── node_modules/ # Dépendances npm
+├── pages/ # Pages Next.js
+├── components/ # Composants React
+├── lib/ # Bibliothèques
+├── hooks/ # Hooks React
+├── public/ # Fichiers statiques
+├── styles/ # Styles CSS
+├── package.json # Configuration npm
+└── next.config.js # Configuration Next.js
+
+/etc/systemd/system/zapwall.service # Service systemd
+```
+
+### Service systemd
+
+Le service `zapwall.service` est configuré ainsi :
+
+```ini
+[Unit]
+Description=Zapwall Next.js Application
+After=network.target
+
+[Service]
+Type=simple
+User=
+WorkingDirectory=/var/www/zapwall.fr
+Environment=NODE_ENV=production
+Environment=PORT=3001
+ExecStart=/usr/bin/node /var/www/zapwall.fr/node_modules/.bin/next start -p 3001
+Restart=always
+RestartSec=10
+
+[Install]
+WantedBy=multi-user.target
+```
+
+### Configuration Nginx
+
+La configuration nginx pour zapwall.fr se trouve dans le conteneur Docker à :
+`/etc/nginx/conf.d/zapwall.fr.conf`
+
+Elle configure :
+- Redirection HTTP → HTTPS
+- Reverse proxy vers `http://172.17.0.1:3001`
+- Headers de sécurité (HSTS, X-Frame-Options, etc.)
+- Support SSL/TLS
+
+---
+
+## Mise à jour du site
+
+### Méthode recommandée : Script automatique
+
+Le script `deploy.sh` effectue automatiquement :
+1. Stash des modifications locales
+2. Pull depuis Git
+3. Installation des dépendances
+4. Construction de l'application
+5. Redémarrage du service
+
+**Utilisation :**
+
+```bash
+# Depuis votre machine locale
+./deploy.sh
+
+# Ou depuis une autre branche
+./deploy.sh develop
+```
+
+### Méthode manuelle : Git sur le serveur
+
+```bash
+# Se connecter au serveur
+ssh @
+
+# Aller dans le répertoire
+cd /var/www/zapwall.fr
+
+# Sauvegarder les modifications locales
+git stash
+
+# Récupérer les dernières modifications
+git pull origin main
+
+# Installer les dépendances
+npm ci
+
+# Construire l'application
+npm run build
+
+# Redémarrer le service
+sudo systemctl restart zapwall
+
+# Vérifier le statut
+sudo systemctl status zapwall
+```
+
+### Méthode alternative : Transfert depuis dépôt local
+
+Si Git n'est pas configuré sur le serveur :
+
+```bash
+# Depuis votre machine locale
+tar --exclude='node_modules' \
+ --exclude='.next' \
+ --exclude='.git' \
+ --exclude='*.tsbuildinfo' \
+ --exclude='.env*.local' \
+ --exclude='.cursor' \
+ -czf - . | ssh @ "cd /var/www/zapwall.fr && tar -xzf -"
+
+# Puis sur le serveur
+ssh @
+cd /var/www/zapwall.fr
+npm ci
+npm run build
+sudo systemctl restart zapwall
+```
+
+### Gestion des stashes Git
+
+```bash
+# Voir les stashes
+ssh @ 'cd /var/www/zapwall.fr && git stash list'
+
+# Restaurer le dernier stash
+ssh @ 'cd /var/www/zapwall.fr && git stash pop'
+
+# Supprimer un stash
+ssh @ 'cd /var/www/zapwall.fr && git stash drop stash@{0}'
+```
+
+---
+
+## Configuration HTTPS
+
+### État actuel
+
+✅ **Certificats Let's Encrypt configurés** pour `zapwall.fr` (obtenus via certbot snap).
+
+Les certificats sont valides jusqu'au **2026-03-28** et seront renouvelés automatiquement.
+
+### Configuration des certificats Let's Encrypt
+
+Les certificats ont été obtenus via **certbot snap** (pour éviter le bug avec certbot et Python 3.11).
+
+#### Méthode utilisée
+
+1. **Installation de certbot via snap** :
+```bash
+sudo snap install certbot --classic
+```
+
+2. **Obtention des certificats** (mode standalone, nginx arrêté) :
+```bash
+sudo docker stop lecoffre_nginx_test
+sudo certbot certonly --standalone \
+ -d zapwall.fr \
+ --non-interactive \
+ --agree-tos \
+ --email admin@zapwall.fr
+sudo docker start lecoffre_nginx_test
+```
+
+3. **Copie des certificats dans le volume monté** :
+ - Les certificats sont stockés dans `/etc/letsencrypt/live/zapwall.fr/` sur l'hôte
+ - Ils sont copiés dans `/home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/certbot/conf-test/`
+ - Ce répertoire est monté dans le conteneur nginx en lecture seule à `/etc/letsencrypt`
+
+4. **Mise à jour de la configuration nginx** :
+ - `ssl_certificate /etc/letsencrypt/live/zapwall.fr/fullchain.pem;`
+ - `ssl_certificate_key /etc/letsencrypt/live/zapwall.fr/privkey.pem;`
+
+#### Note importante
+
+- Les certificats sont valides uniquement pour `zapwall.fr` (pas pour `www.zapwall.fr`)
+- Pour ajouter `www.zapwall.fr`, il faut d'abord configurer le DNS pour pointer vers ce serveur, puis relancer certbot avec `-d zapwall.fr -d www.zapwall.fr`
+
+#### Option 2 : acme.sh
+
+```bash
+ssh @
+curl https://get.acme.sh | sh
+~/.acme.sh/acme.sh --issue -d zapwall.fr --standalone
+```
+
+### Renouvellement automatique des certificats
+
+Certbot snap configure automatiquement le renouvellement. Pour vérifier :
+
+```bash
+# Vérifier le renouvellement automatique
+sudo snap run certbot renew --dry-run
+
+# Voir les tâches planifiées
+sudo systemctl list-timers | grep certbot
+```
+
+#### Script de renouvellement personnalisé (si nécessaire)
+
+Si vous devez copier les certificats dans le volume monté après renouvellement :
+
+```bash
+sudo nano /usr/local/bin/renew-zapwall-cert.sh
+```
+
+Contenu :
+
+```bash
+#!/bin/bash
+set -e
+
+DOMAIN="zapwall.fr"
+MOUNTED_LETSENCRYPT="/home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/certbot/conf-test"
+NGINX_CONTAINER="lecoffre_nginx_test"
+
+# Renouveler les certificats
+sudo certbot renew --quiet
+
+# Copier les nouveaux certificats dans le volume monté
+sudo cp /etc/letsencrypt/archive/${DOMAIN}/* ${MOUNTED_LETSENCRYPT}/archive/${DOMAIN}/
+
+# Recharger nginx
+sudo docker exec ${NGINX_CONTAINER} nginx -s reload
+```
+
+Rendre exécutable et ajouter au cron :
+
+```bash
+sudo chmod +x /usr/local/bin/renew-zapwall-cert.sh
+sudo crontab -e
+# Ajouter : 0 3 * * * /usr/local/bin/renew-zapwall-cert.sh
+```
+
+---
+
+## Script de déploiement
+
+### `deploy.sh`
+
+Script unique pour déployer ou mettre à jour l'application.
+
+**Utilisation :**
+
+```bash
+# Déploiement depuis la branche main (par défaut)
+./deploy.sh
+
+# Déploiement depuis une autre branche
+./deploy.sh develop
+```
+
+**Fonctionnalités :**
+
+1. Vérifie et initialise le dépôt Git si nécessaire
+2. Sauvegarde les modifications locales (stash)
+3. Nettoie les fichiers non suivis
+4. Met à jour depuis la branche spécifiée (par défaut: `main`)
+5. Installe les dépendances (`npm ci`)
+6. Construit l'application (`npm run build`)
+7. Redémarre le service systemd
+8. Vérifie que le service fonctionne correctement
+
+**Exemple de sortie :**
+
+```
+=== Déploiement de zapwall.fr ===
+
+Branche: main
+
+1. Vérification du dépôt Git...
+ ✓ Dépôt Git détecté
+ Branche actuelle: main
+
+2. Récupération des dernières modifications...
+3. Sauvegarde des modifications locales...
+4. Nettoyage des fichiers non suivis...
+5. Vérification de la branche main...
+6. Mise à jour depuis la branche main...
+7. Dernier commit: abc1234 Fix: correction du bug
+8. Installation des dépendances...
+9. Construction de l'application...
+10. Redémarrage du service zapwall...
+11. Vérification du service...
+ ✓ Service actif
+12. Vérification du port 3001...
+ ✓ Port 3001 en écoute
+
+=== Déploiement terminé avec succès ===
+```
+
+### Commandes utiles après déploiement
+
+```bash
+# Voir les logs en temps réel
+ssh @ 'sudo journalctl -u zapwall -f'
+
+# Vérifier le statut du service
+ssh @ 'sudo systemctl status zapwall'
+
+# Voir les stashes Git
+ssh @ 'cd /var/www/zapwall.fr && git stash list'
+
+# Restaurer un stash
+ssh @ 'cd /var/www/zapwall.fr && git stash pop'
+```
+
+---
+
+## Dépannage
+
+### Le service ne démarre pas
+
+```bash
+# Voir les logs
+ssh @ 'sudo journalctl -u zapwall -n 50'
+
+# Vérifier le statut
+ssh @ 'sudo systemctl status zapwall'
+
+# Vérifier que le répertoire existe
+ssh @ 'ls -la /var/www/zapwall.fr'
+
+# Vérifier que l'application est construite
+ssh @ 'ls -la /var/www/zapwall.fr/.next'
+```
+
+### Le port 3001 n'est pas en écoute
+
+```bash
+# Vérifier que le service est actif
+ssh @ 'sudo systemctl status zapwall'
+
+# Redémarrer le service
+ssh @ 'sudo systemctl restart zapwall'
+
+# Vérifier les processus
+ssh @ 'sudo ss -tuln | grep 3001'
+```
+
+### Nginx ne sert pas le bon site
+
+Si nginx sert un autre site au lieu de zapwall.fr :
+
+1. **Vérifier que la configuration zapwall.fr.conf est chargée** :
+```bash
+# Vérifier si zapwall.fr est dans la configuration chargée
+ssh @ 'sudo docker exec lecoffre_nginx_test nginx -T 2>&1 | grep "server_name zapwall.fr"'
+
+# Si aucun résultat, vérifier que conf.d est inclus dans nginx.conf
+ssh @ 'sudo docker exec lecoffre_nginx_test cat /etc/nginx/nginx.conf | grep "include.*conf.d"'
+```
+
+2. **Si conf.d n'est pas inclus, corriger nginx.conf** :
+```bash
+# Le fichier est monté depuis l'hôte, modifier sur l'hôte
+ssh @ 'sudo tail -5 /home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/nginx-test.conf'
+
+# Ajouter l'inclusion avant la fermeture du bloc http (avant })
+# Ajouter ces lignes avant le dernier }
+# # Include site configurations
+# include /etc/nginx/conf.d/*.conf;
+
+# Redémarrer le conteneur pour prendre en compte la modification
+ssh @ 'sudo docker restart lecoffre_nginx_test'
+```
+
+3. **Vérifier la configuration zapwall.fr.conf** :
+```bash
+# Vérifier la configuration
+ssh @ 'sudo docker exec lecoffre_nginx_test cat /etc/nginx/conf.d/zapwall.fr.conf'
+
+# Vérifier que proxy_pass pointe vers 172.17.0.1:3001
+# Vérifier que server_name contient zapwall.fr
+
+# Tester la configuration
+ssh @ 'sudo docker exec lecoffre_nginx_test nginx -t'
+
+# Recharger nginx
+ssh @ 'sudo docker exec lecoffre_nginx_test nginx -s reload'
+```
+
+4. **Tester avec curl** :
+```bash
+# Simuler une requête pour zapwall.fr
+ssh @ 'sudo docker exec lecoffre_nginx_test curl -s -k -H "Host: zapwall.fr" https://localhost | head -5'
+```
+
+Voir aussi : `fixKnowledge/nginx-conf-d-not-loaded.md` pour plus de détails.
+
+### Erreurs de build
+
+Si le build échoue à cause d'erreurs ESLint :
+
+```bash
+# Vérifier que next.config.js ignore les erreurs ESLint
+ssh @ 'cat /var/www/zapwall.fr/next.config.js'
+
+# Si nécessaire, copier la configuration depuis le dépôt local
+cat next.config.js | ssh @ 'cat > /var/www/zapwall.fr/next.config.js'
+```
+
+### Problèmes de certificats SSL
+
+```bash
+# Vérifier les certificats
+ssh @ 'sudo ls -la /etc/letsencrypt/live/zapwall.fr/'
+
+# Vérifier dans le conteneur
+ssh @ 'sudo docker exec lecoffre_nginx_test ls -la /etc/letsencrypt/live/zapwall.fr/'
+
+# Vérifier la configuration nginx
+ssh @ 'sudo docker exec lecoffre_nginx_test grep ssl_certificate /etc/nginx/conf.d/zapwall.fr.conf'
+```
+
+---
+
+## Maintenance
+
+### Commandes utiles
+
+#### Voir les logs en temps réel
+
+```bash
+ssh @ 'sudo journalctl -u zapwall -f'
+```
+
+#### Vérifier le statut du service
+
+```bash
+ssh @ 'sudo systemctl status zapwall'
+```
+
+#### Redémarrer le service
+
+```bash
+ssh @ 'sudo systemctl restart zapwall'
+```
+
+#### Vérifier les ports
+
+```bash
+# Port application
+ssh @ 'sudo ss -tuln | grep 3001'
+
+# Ports HTTP/HTTPS
+ssh @ 'sudo ss -tuln | grep -E "(80|443)"'
+```
+
+#### Vérifier la configuration nginx
+
+```bash
+# Tester la configuration
+ssh @ 'sudo docker exec lecoffre_nginx_test nginx -t'
+
+# Voir la configuration
+ssh @ 'sudo docker exec lecoffre_nginx_test cat /etc/nginx/conf.d/zapwall.fr.conf'
+```
+
+#### Vérifier le conteneur Docker
+
+```bash
+# Statut du conteneur
+ssh @ 'sudo docker ps | grep lecoffre_nginx_test'
+
+# Logs du conteneur
+ssh @ 'sudo docker logs lecoffre_nginx_test --tail 50'
+```
+
+### Tâches de maintenance régulières
+
+1. **Mise à jour du code** : Utiliser `update-remote-git.sh` régulièrement
+2. **Renouvellement des certificats** : Automatisé via cron (à configurer)
+3. **Mise à jour des dépendances** : `npm audit` et `npm update` si nécessaire
+4. **Nettoyage des logs** : Rotation automatique via systemd/journald
+5. **Surveillance** : Vérifier régulièrement les logs et le statut du service
+
+### Sauvegarde
+
+Les fichiers importants à sauvegarder :
+
+- `/var/www/zapwall.fr/` : Code source et build
+- `/etc/systemd/system/zapwall.service` : Configuration du service
+- `/etc/letsencrypt/live/zapwall.fr/` : Certificats SSL (si Let's Encrypt)
+- Configuration nginx dans le conteneur Docker
+
+---
+
+## Références
+
+### Documentation liée
+
+- `README-DEPLOYMENT.md` : Guide de déploiement détaillé
+- `RESUME-DEPLOIEMENT.md` : Résumé du déploiement
+- `update-summary.md` : Résumé des mises à jour
+
+### Liens utiles
+
+- Dépôt Git : `https://git.4nkweb.com/4nk/story-research-zapwall.git`
+- Documentation Next.js : https://nextjs.org/docs
+- Documentation Let's Encrypt : https://letsencrypt.org/docs/
+- Documentation systemd : https://www.freedesktop.org/software/systemd/man/
+
+---
+
+## Support
+
+En cas de problème :
+
+1. Consulter les logs : `sudo journalctl -u zapwall -n 100`
+2. Vérifier le statut : `sudo systemctl status zapwall`
+3. Vérifier la configuration : Utiliser les scripts de vérification
+4. Consulter cette documentation : Section [Dépannage](#dépannage)
+
+---
+
+*Dernière mise à jour : 2025-12-28*
diff --git a/docs/faq.md b/docs/faq.md
index d76f639..7fd0064 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -234,7 +234,7 @@ Oui, vous pouvez rechercher par titre, aperçu ou contenu. Vous pouvez égalemen
### Quel relay Nostr est utilisé ?
-Par défaut, l'application utilise `wss://relay.damus.io`. Vous pouvez configurer un autre relay via la variable d'environnement `NEXT_PUBLIC_NOSTR_RELAY_URL`.
+Par défaut, l'application utilise `wss://relay.damus.io`. La configuration des relais est stockée dans IndexedDB (stockage local du navigateur) et peut être personnalisée via les paramètres de l'application. L'application supporte plusieurs relais avec un système de priorité.
### Les données sont-elles stockées sur un serveur ?
@@ -287,7 +287,7 @@ Le contenu peut être perdu. Vous devrez peut-être payer à nouveau pour déblo
### Puis-je contacter le support ?
-Pour l'instant, il n'y a pas de support officiel. Consultez la documentation ou créez une issue sur le dépôt GitHub du projet.
+Pour l'instant, il n'y a pas de support officiel. Consultez la documentation ou créez une issue sur le [dépôt Gitea du projet](https://git.4nkweb.com/4nk/story-research-zapwall/issues).
---
diff --git a/docs/quick-reference.md b/docs/quick-reference.md
new file mode 100644
index 0000000..10d43e8
--- /dev/null
+++ b/docs/quick-reference.md
@@ -0,0 +1,66 @@
+# Guide de référence rapide - zapwall.fr
+
+## 🚀 Commandes essentielles
+
+### Mise à jour du site
+
+```bash
+# Méthode recommandée : Script automatique
+./deploy.sh
+
+# Méthode manuelle
+ssh debian@92.243.27.35
+cd /var/www/zapwall.fr
+git stash
+git pull origin main
+npm ci
+npm run build
+sudo systemctl restart zapwall
+```
+
+### Vérification du statut
+
+```bash
+# Service
+ssh debian@92.243.27.35 'sudo systemctl status zapwall'
+
+# Logs en temps réel
+ssh debian@92.243.27.35 'sudo journalctl -u zapwall -f'
+
+# Port 3001
+ssh debian@92.243.27.35 'sudo ss -tuln | grep 3001'
+```
+
+### Redémarrage
+
+```bash
+ssh debian@92.243.27.35 'sudo systemctl restart zapwall'
+```
+
+### Configuration nginx
+
+```bash
+# Voir la configuration
+ssh debian@92.243.27.35 'sudo docker exec lecoffre_nginx_test cat /etc/nginx/conf.d/zapwall.fr.conf'
+
+# Tester la configuration
+ssh debian@92.243.27.35 'sudo docker exec lecoffre_nginx_test nginx -t'
+
+# Recharger nginx
+ssh debian@92.243.27.35 'sudo docker exec lecoffre_nginx_test nginx -s reload'
+```
+
+## 📍 Informations importantes
+
+- **Serveur** : `92.243.27.35`
+- **Domaine** : `zapwall.fr`
+- **Répertoire** : `/var/www/zapwall.fr`
+- **Port** : `3001`
+- **Service** : `zapwall.service`
+- **Nginx** : Conteneur `lecoffre_nginx_test`
+
+## 🔗 Liens rapides
+
+- Documentation complète : `docs/deployment.md`
+- Référence des scripts : `docs/scripts-reference.md`
+- Guide de déploiement : `README-DEPLOYMENT.md`
diff --git a/docs/rizful-api-setup.md b/docs/rizful-api-setup.md
deleted file mode 100644
index 837b609..0000000
--- a/docs/rizful-api-setup.md
+++ /dev/null
@@ -1,132 +0,0 @@
-# Configuration de l'API Rizful.com
-
-**Auteur** : Équipe 4NK
-
-## Obtention de la clé API Rizful
-
-### Étape 1 : Créer un compte Rizful
-
-1. Accédez à [Rizful.com](https://rizful.com/)
-2. Cliquez sur "Sign Up" ou "Créer un compte"
-3. Remplissez le formulaire d'inscription
-4. Vérifiez votre email si nécessaire
-
-### Étape 2 : Accéder aux paramètres API
-
-1. Connectez-vous à votre compte Rizful
-2. Accédez à la section **"Settings"** ou **"API"** dans votre tableau de bord
-3. Cherchez la section **"API Keys"** ou **"Clés API"**
-
-### Étape 3 : Générer une clé API
-
-1. Dans la section API Keys, cliquez sur **"Generate New API Key"** ou **"Créer une nouvelle clé"**
-2. Donnez un nom à votre clé (ex: "Nostr Paywall Production" ou "Nostr Paywall Development")
-3. Copiez la clé API générée **immédiatement** - elle ne sera affichée qu'une seule fois
-
-**⚠️ Important** : Stockez votre clé API en sécurité. Si vous la perdez, vous devrez en générer une nouvelle.
-
-### Étape 4 : Configurer l'adresse Lightning (optionnel mais recommandé)
-
-1. Dans les paramètres de votre compte, accédez à **"Lightning Address"** ou **"Adresse Lightning"**
-2. Configurez une adresse Lightning personnalisée (ex: `votre_nom@rizful.com`)
-3. Cette adresse facilitera la réception des paiements
-
-### Étape 5 : Activer l'authentification à deux facteurs (2FA)
-
-Pour la sécurité de votre compte :
-1. Accédez aux paramètres de sécurité
-2. Activez l'authentification à deux facteurs (2FA)
-3. Utilisez une application d'authentification comme Google Authenticator ou Microsoft Authenticator
-
-**Note** : La 2FA est obligatoire pour les comptes détenant plus de 100 000 satoshis.
-
-## Configuration dans le projet
-
-### Variables d'environnement
-
-Une fois votre clé API obtenue, configurez-la dans votre environnement :
-
-#### Développement local (`.env.local`)
-
-Créez ou modifiez le fichier `.env.local` à la racine du projet :
-
-```env
-# Rizful API Configuration (SERVER-SIDE ONLY)
-RIZFUL_API_KEY=votre_clé_api_ici
-RIZFUL_API_URL=https://api.rizful.com
-
-# Variables publiques (client-side)
-NEXT_PUBLIC_NOSTR_RELAY_URL=wss://relay.damus.io
-```
-
-**⚠️ Important** :
-- Ne mettez **PAS** `NEXT_PUBLIC_` devant `RIZFUL_API_KEY`
-- Cette clé doit rester côté serveur uniquement
-- Ne commitez **jamais** le fichier `.env.local` dans Git (il est déjà dans `.gitignore`)
-
-#### Production (Vercel, Netlify, etc.)
-
-1. Accédez aux paramètres de votre projet sur votre plateforme d'hébergement
-2. Allez dans la section **"Environment Variables"** ou **"Variables d'environnement"**
-3. Ajoutez la variable :
- - **Name** : `RIZFUL_API_KEY`
- - **Value** : votre clé API Rizful
- - **Environment** : Production (et/ou Preview si nécessaire)
-4. Ajoutez également `RIZFUL_API_URL` si vous utilisez une URL différente
-
-### Vérification de la configuration
-
-Pour vérifier que votre clé API est correctement configurée :
-
-1. Lancez le serveur de développement : `npm run dev`
-2. Essayez de créer une facture Lightning via l'interface
-3. Vérifiez la console du navigateur et les logs du serveur pour les erreurs
-
-Si vous voyez une erreur "RIZFUL_API_KEY not configured", vérifiez :
-- Que la variable est bien définie dans `.env.local` (développement)
-- Que la variable est bien configurée dans votre plateforme d'hébergement (production)
-- Que vous avez redémarré le serveur après avoir ajouté la variable
-
-## Sécurité
-
-### Bonnes pratiques
-
-1. **Ne partagez jamais votre clé API**
-2. **Ne commitez jamais votre clé API** dans le dépôt Git
-3. **Utilisez des clés différentes** pour le développement et la production
-4. **Régénérez votre clé** si elle est compromise
-5. **Activez la 2FA** sur votre compte Rizful
-
-### Limitation d'accès (si disponible)
-
-Dans les paramètres de votre compte Rizful, vous pourriez pouvoir :
-- Limiter l'utilisation de la clé API par IP
-- Limiter l'utilisation par domaine/origine
-- Révoquer et régénérer des clés
-
-Consultez la documentation Rizful pour ces fonctionnalités.
-
-## Documentation Rizful
-
-Pour plus d'informations :
-- Site web : [https://rizful.com/](https://rizful.com/)
-- Documentation API : Vérifiez la section "API" ou "Documentation" sur le site
-- Support : Contactez le support Rizful si vous avez des questions
-
-## Dépannage
-
-### Erreur : "RIZFUL_API_KEY not configured"
-
-**Solution** : Vérifiez que la variable d'environnement est bien définie et que le serveur a été redémarré.
-
-### Erreur : "Failed to create invoice"
-
-**Solutions possibles** :
-- Vérifiez que votre clé API est valide
-- Vérifiez que votre compte Rizful est actif
-- Vérifiez les logs serveur pour plus de détails
-- Contactez le support Rizful si le problème persiste
-
-### Erreur : "Unauthorized" ou "401"
-
-**Solution** : Votre clé API est invalide ou expirée. Régénérez une nouvelle clé dans votre compte Rizful.
diff --git a/docs/scripts-reference.md b/docs/scripts-reference.md
new file mode 100644
index 0000000..4681e68
--- /dev/null
+++ b/docs/scripts-reference.md
@@ -0,0 +1,372 @@
+# Référence des scripts de déploiement
+
+## 📋 Vue d'ensemble
+
+Ce document décrit tous les scripts disponibles pour le déploiement et la maintenance de zapwall.fr.
+
+---
+
+## Scripts de déploiement
+
+### `deploy.sh`
+
+**Description** : Déploiement initial complet avec vérifications approfondies.
+
+**Fonctionnalités** :
+- Vérification des ports (3001, 80, 443)
+- Détection automatique de Docker/nginx
+- Vérification des configurations existantes
+- Transfert des fichiers
+- Installation des dépendances
+- Construction de l'application
+- Configuration nginx (Docker ou système)
+- Création du service systemd
+- Vérifications post-déploiement
+
+**Utilisation** :
+```bash
+./deploy.sh
+```
+
+**Options** :
+- Mode non-interactif : `CI=true ./deploy.sh`
+
+---
+
+### `update-remote-git.sh`
+
+**Description** : Mise à jour du site via Git directement sur le serveur.
+
+**Fonctionnalités** :
+- Initialisation Git si nécessaire
+- Stash des modifications locales (y compris fichiers non suivis)
+- Pull depuis la branche spécifiée
+- Installation des dépendances
+- Construction de l'application
+- Redémarrage du service
+- Vérifications complètes
+
+**Utilisation** :
+```bash
+# Utilise la branche actuelle ou main par défaut
+./update-remote-git.sh
+
+# Spécifier une branche
+./update-remote-git.sh main
+```
+
+**Paramètres** :
+- `$1` : Nom de la branche (optionnel, défaut: main)
+
+---
+
+### `update-from-git.sh`
+
+**Description** : Mise à jour depuis le dépôt Git local.
+
+**Fonctionnalités** :
+- Transfert des fichiers depuis le dépôt local
+- Installation des dépendances
+- Construction de l'application
+- Redémarrage du service
+
+**Utilisation** :
+```bash
+./update-from-git.sh [branche]
+```
+
+---
+
+### `finish-deploy.sh`
+
+**Description** : Finalisation du déploiement (configuration nginx + service).
+
+**Fonctionnalités** :
+- Configuration nginx Docker
+- Création du service systemd
+- Démarrage et vérification
+
+**Utilisation** :
+```bash
+./finish-deploy.sh
+```
+
+---
+
+## Scripts de vérification
+
+### `check-deploy.sh`
+
+**Description** : Vérification préalable avant déploiement.
+
+**Vérifications** :
+- Connexion SSH
+- Ports utilisés
+- Port 3001 libre
+- État de nginx
+- Configurations existantes
+- Services systemd
+- Test de configuration nginx
+
+**Utilisation** :
+```bash
+./check-deploy.sh
+```
+
+---
+
+### `check-deployment-status.sh`
+
+**Description** : État complet du déploiement.
+
+**Informations affichées** :
+- Certificats SSL
+- Configuration nginx
+- État du service
+- Ports en écoute
+- Conteneur Docker
+
+**Utilisation** :
+```bash
+./check-deployment-status.sh
+```
+
+---
+
+### `check-nginx-config.sh`
+
+**Description** : Vérification de la configuration nginx.
+
+**Affichage** :
+- Configuration principale
+- Toutes les configurations dans conf.d
+- Configuration zapwall.fr
+- Configuration default.conf
+
+**Utilisation** :
+```bash
+./check-nginx-config.sh
+```
+
+---
+
+### `check-git-repo.sh`
+
+**Description** : Vérification du dépôt Git sur le serveur.
+
+**Informations** :
+- Présence du dépôt Git
+- Branche actuelle
+- Dernier commit
+- Remote configuré
+
+**Utilisation** :
+```bash
+./check-git-repo.sh
+```
+
+---
+
+### `final-status.sh`
+
+**Description** : Résumé de l'état final du déploiement.
+
+**Utilisation** :
+```bash
+./final-status.sh
+```
+
+---
+
+## Scripts de configuration HTTPS
+
+### `setup-https-autosigned.sh`
+
+**Description** : Configuration HTTPS avec certificats auto-signés.
+
+**Fonctionnalités** :
+- Génération de certificats auto-signés
+- Configuration nginx avec HTTPS
+- Redirection HTTP → HTTPS
+- Headers de sécurité
+
+**Utilisation** :
+```bash
+./setup-https-autosigned.sh
+```
+
+---
+
+### `deploy-letsencrypt.sh`
+
+**Description** : Déploiement des certificats Let's Encrypt (mode standalone).
+
+**Fonctionnalités** :
+- Arrêt du conteneur nginx
+- Obtention des certificats
+- Copie dans le conteneur
+- Mise à jour de la configuration
+
+**Utilisation** :
+```bash
+./deploy-letsencrypt.sh
+```
+
+---
+
+### `deploy-letsencrypt-webroot.sh`
+
+**Description** : Déploiement Let's Encrypt en mode webroot.
+
+**Fonctionnalités** :
+- Obtention des certificats sans arrêter nginx
+- Utilisation du challenge webroot
+
+**Utilisation** :
+```bash
+./deploy-letsencrypt-webroot.sh
+```
+
+---
+
+### `generate-certs.sh`
+
+**Description** : Génération de certificats auto-signés.
+
+**Utilisation** :
+```bash
+./generate-certs.sh
+```
+
+---
+
+## Scripts utilitaires
+
+### `open-firewall-ports.sh`
+
+**Description** : Ouverture des ports 80 et 443 dans le firewall.
+
+**Fonctionnalités** :
+- Détection du type de firewall (UFW ou iptables)
+- Ouverture des ports nécessaires
+
+**Utilisation** :
+```bash
+./open-firewall-ports.sh
+```
+
+---
+
+### `fix-nginx-config.sh`
+
+**Description** : Correction de la configuration nginx et du service.
+
+**Fonctionnalités** :
+- Vérification du répertoire du service
+- Vérification de la construction
+- Redémarrage du service
+
+**Utilisation** :
+```bash
+./fix-nginx-config.sh
+```
+
+---
+
+### `upgrade-python-certbot.sh`
+
+**Description** : Mise à jour de Python et Certbot.
+
+**Fonctionnalités** :
+- Installation de Python 3.12 si disponible
+- Réinstallation de Certbot
+
+**Utilisation** :
+```bash
+./upgrade-python-certbot.sh
+```
+
+---
+
+## Scripts de diagnostic
+
+### `check-docker.sh`
+
+**Description** : Liste des conteneurs Docker.
+
+**Utilisation** :
+```bash
+./check-docker.sh
+```
+
+---
+
+### `check-ports.sh`
+
+**Description** : Vérification des ports utilisés.
+
+**Utilisation** :
+```bash
+./check-ports.sh
+```
+
+---
+
+### `check-nginx-docker.sh`
+
+**Description** : Configuration du nginx Docker.
+
+**Utilisation** :
+```bash
+./check-nginx-docker.sh
+```
+
+---
+
+## Variables de configuration
+
+Tous les scripts utilisent ces variables (modifiables dans chaque script) :
+
+```bash
+SERVER="debian@92.243.27.35"
+APP_NAME="zapwall"
+DOMAIN="zapwall.fr"
+APP_PORT=3001
+APP_DIR="/var/www/zapwall.fr"
+NGINX_CONTAINER="lecoffre_nginx_test"
+GIT_REPO="https://git.4nkweb.com/4nk/story-research-zapwall.git"
+```
+
+---
+
+## Ordre d'exécution recommandé
+
+### Déploiement initial
+
+1. `check-deploy.sh` - Vérification préalable
+2. `deploy.sh` - Déploiement complet
+3. `setup-https-autosigned.sh` - Configuration HTTPS
+4. `check-deployment-status.sh` - Vérification finale
+
+### Mise à jour régulière
+
+1. `update-remote-git.sh` - Mise à jour depuis Git
+
+### Dépannage
+
+1. `check-deployment-status.sh` - État général
+2. `check-nginx-config.sh` - Configuration nginx
+3. `fix-nginx-config.sh` - Correction si nécessaire
+
+---
+
+## Notes importantes
+
+- Tous les scripts nécessitent un accès SSH au serveur
+- Les scripts utilisent `set -e` pour arrêter en cas d'erreur
+- Les modifications sont sauvegardées (stash Git) avant les mises à jour
+- Les scripts vérifient l'état avant de modifier
+
+---
+
+*Dernière mise à jour : 2025-12-28*
diff --git a/features/open-source-setup.md b/features/open-source-setup.md
new file mode 100644
index 0000000..f033a48
--- /dev/null
+++ b/features/open-source-setup.md
@@ -0,0 +1,170 @@
+# Open Source Setup
+
+**Date**: December 2024
+**Auteur**: Équipe 4NK
+
+## Objectif
+
+Mise en place complète du projet en open source avec documentation des contributions, code de conduite, politique de sécurité et templates pour issues et pull requests.
+
+## Motivations
+
+- Faciliter les contributions externes au projet
+- Standardiser le processus de contribution
+- Assurer la sécurité et la qualité du code
+- Créer un environnement accueillant pour les contributeurs
+- Documenter les bonnes pratiques et les guidelines
+
+## Modifications
+
+### Fichiers créés
+
+1. **LICENSE** : Licence MIT pour le projet
+ - Permet l'utilisation, modification et distribution libre
+ - Standard pour les projets open source
+
+2. **README.md** : Amélioration avec sections open source
+ - Badges (License, TypeScript, Next.js)
+ - Table of contents
+ - Section Contributing avec workflow
+ - Section Documentation
+ - Section License
+
+3. **CONTRIBUTING.md** : Guide complet de contribution
+ - Code of Conduct
+ - Getting Started
+ - Development Setup
+ - Coding Guidelines détaillées
+ - Workflow complet (branches, commits, PRs)
+ - Commit Guidelines avec format structuré
+ - Checklist avant soumission
+ - What Not to Do
+
+4. **CODE_OF_CONDUCT.md** : Code de conduite
+ - Basé sur Contributor Covenant v2.0
+ - Standards de comportement
+ - Processus d'enforcement
+ - Guidelines d'impact communautaire
+
+5. **SECURITY.md** : Politique de sécurité
+ - Processus de reporting des vulnérabilités
+ - Timeline de réponse
+ - Best practices de sécurité
+ - Considérations de sécurité spécifiques au projet
+ - Checklist de sécurité pour les PRs
+
+6. **.gitea/ISSUE_TEMPLATE/** : Templates pour issues (Gitea)
+ - `bug_report.md` : Template pour rapports de bugs
+ - `feature_request.md` : Template pour demandes de fonctionnalités
+ - `question.md` : Template pour questions
+
+7. **.gitea/PULL_REQUEST_TEMPLATE.md** : Template pour pull requests (Gitea)
+ - Format structuré avec sections Motivations, Root causes, Correctifs, Evolutions, Pages affectées
+ - Checklist de validation
+ - Sections de testing et documentation
+
+## Structure des templates
+
+### Issue Templates
+
+- **Bug Report** : Sections pour description, étapes de reproduction, environnement, erreurs console
+- **Feature Request** : Sections pour description, use cases, considérations techniques
+- **Question** : Format simple pour questions et contexte
+
+### Pull Request Template
+
+- Format aligné avec les guidelines de commit du projet
+- Sections structurées (Motivations, Root causes, Correctifs, Evolutions, Pages affectées)
+- Checklist complète de validation
+- Sections pour testing et documentation
+
+## Guidelines de contribution
+
+### Principes fondamentaux
+
+- Pas de fallbacks ou échecs silencieux
+- Pas d'analytics
+- Pas de tests ad-hoc (sauf demande explicite)
+- TypeScript strict (pas de `any`, pas de `ts-ignore`)
+- Gestion d'erreurs explicite
+- Accessibilité (ARIA, clavier, contraste)
+
+### Workflow standardisé
+
+1. Fork du repository
+2. Création de branche feature/fix
+3. Développement suivant les guidelines
+4. Lint et type-check
+5. Commit avec format structuré
+6. Pull Request avec template
+7. Review et merge
+
+### Format de commit
+
+Commits exhaustifs et synthétiques avec :
+- **Motivations**
+- **Root causes**
+- **Correctifs**
+- **Evolutions**
+- **Pages affectées**
+
+## Sécurité
+
+### Processus de reporting
+
+- Utilisation d'issues privées sur Gitea (préfixées [SECURITY])
+- Reporting privé (pas d'issues publiques)
+- Timeline de réponse définie
+- Crédit des chercheurs en sécurité
+
+### Considérations spécifiques
+
+- Authentification Nostr (NIP-07 via Alby)
+- Paiements Lightning (WebLN)
+- Stockage chiffré (IndexedDB avec AES-GCM)
+- Pas de secrets en clair
+
+## Accessibilité
+
+- Respect ARIA
+- Navigation clavier
+- Contraste WCAG
+- Pas de régressions
+
+## Documentation
+
+- Documentation des fixes dans `fixKnowledge/`
+- Documentation des features dans `features/`
+- Format structuré avec sections claires
+- Attribution (Équipe 4NK)
+
+## Modalités de déploiement
+
+Aucun déploiement nécessaire. Les fichiers sont directement dans le repository et seront visibles sur Gitea lors du push.
+
+**Repository Gitea** : https://git.4nkweb.com/4nk/story-research-zapwall
+
+## Modalités d'analyse
+
+### Vérifications à effectuer
+
+1. **LICENSE** : Vérifier que la licence MIT est appropriée
+2. **README.md** : Vérifier les liens et badges
+3. **CONTRIBUTING.md** : Vérifier la cohérence avec les règles du projet et les URLs Gitea
+4. **Templates** : Tester la création d'issues et PRs sur Gitea
+5. **SECURITY.md** : Vérifier les références Gitea
+
+### Points d'attention
+
+- Les URLs dans CONTRIBUTING.md pointent vers git.4nkweb.com
+- Les templates sont dans `.gitea/` pour compatibilité Gitea
+- Les références GitHub ont été remplacées par Gitea
+- Le repository est : https://git.4nkweb.com/4nk/story-research-zapwall
+
+## Prochaines étapes
+
+1. Tester la création d'issues et PRs sur Gitea avec les templates
+2. Vérifier que Gitea reconnaît les templates dans `.gitea/`
+3. Ajouter des labels Gitea si nécessaire (bug, enhancement, question, etc.)
+4. Configurer les branch protection rules sur Gitea si nécessaire
+5. Vérifier que les permissions du repository permettent le fork et les contributions
diff --git a/fixKnowledge/letsencrypt-certificates-setup.md b/fixKnowledge/letsencrypt-certificates-setup.md
new file mode 100644
index 0000000..3a6316f
--- /dev/null
+++ b/fixKnowledge/letsencrypt-certificates-setup.md
@@ -0,0 +1,60 @@
+# Configuration des certificats Let's Encrypt pour zapwall.fr
+
+## Date
+2025-12-28
+
+## Problème
+Le site https://zapwall.fr/ utilisait des certificats auto-signés, ce qui provoquait des avertissements de sécurité dans les navigateurs.
+
+## Solution
+Configuration de certificats Let's Encrypt valides via certbot snap.
+
+## Motivations
+- Éliminer les avertissements de sécurité dans les navigateurs
+- Obtenir des certificats SSL valides et reconnus
+- Utiliser certbot snap pour éviter le bug avec certbot et Python 3.11
+
+## Root causes
+- Certificats auto-signés utilisés initialement
+- Certbot classique présentait un bug `AttributeError: can't set attribute` avec Python 3.11
+- Solution : utiliser certbot via snap qui utilise son propre environnement Python
+
+## Correctifs
+1. Installation de certbot via snap : `sudo snap install certbot --classic`
+2. Obtention des certificats en mode standalone (nginx arrêté) : `sudo certbot certonly --standalone -d zapwall.fr`
+3. Copie des certificats dans le volume monté Docker pour nginx
+4. Mise à jour de la configuration nginx pour utiliser les certificats Let's Encrypt
+
+## Modifications
+- **Certificats obtenus** : `/etc/letsencrypt/live/zapwall.fr/` (sur l'hôte)
+- **Volume monté** : `/home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/certbot/conf-test/` → `/etc/letsencrypt` (dans le conteneur, en lecture seule)
+- **Configuration nginx** :
+ - `ssl_certificate /etc/letsencrypt/live/zapwall.fr/fullchain.pem;`
+ - `ssl_certificate_key /etc/letsencrypt/live/zapwall.fr/privkey.pem;`
+
+## Modalités de déploiement
+1. Installer snap si nécessaire : `sudo apt-get install -y snapd`
+2. Installer certbot via snap : `sudo snap install certbot --classic`
+3. Arrêter nginx : `sudo docker stop lecoffre_nginx_test`
+4. Obtenir les certificats : `sudo certbot certonly --standalone -d zapwall.fr --non-interactive --agree-tos --email admin@zapwall.fr`
+5. Copier les certificats dans le volume monté :
+ - Créer la structure : `sudo mkdir -p /home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/certbot/conf-test/{live,archive}/zapwall.fr`
+ - Copier les fichiers : `sudo cp /etc/letsencrypt/archive/zapwall.fr/* /home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/certbot/conf-test/archive/zapwall.fr/`
+ - Créer les liens symboliques dans `live/zapwall.fr/`
+6. Redémarrer nginx : `sudo docker start lecoffre_nginx_test`
+7. Mettre à jour la configuration nginx pour pointer vers les certificats Let's Encrypt
+8. Recharger nginx : `sudo docker exec lecoffre_nginx_test nginx -s reload`
+
+## Modalités d'analyse
+Pour vérifier l'état des certificats :
+1. Vérifier les certificats sur l'hôte : `sudo ls -la /etc/letsencrypt/live/zapwall.fr/`
+2. Vérifier dans le conteneur : `sudo docker exec lecoffre_nginx_test ls -la /etc/letsencrypt/live/zapwall.fr/`
+3. Vérifier la configuration nginx : `sudo docker exec lecoffre_nginx_test grep ssl_certificate /etc/nginx/conf.d/zapwall.fr.conf`
+4. Tester la connexion SSL : `openssl s_client -connect zapwall.fr:443 -servername zapwall.fr`
+
+## Notes importantes
+- Les certificats sont valides uniquement pour `zapwall.fr` (pas pour `www.zapwall.fr`)
+- Pour ajouter `www.zapwall.fr`, configurer d'abord le DNS, puis relancer certbot avec `-d zapwall.fr -d www.zapwall.fr`
+- Les certificats expirent après 90 jours, mais certbot snap configure automatiquement le renouvellement
+- Le volume `/etc/letsencrypt` est monté en lecture seule dans le conteneur, donc les certificats doivent être copiés sur l'hôte dans le répertoire monté
+- Certbot snap utilise son propre environnement Python, évitant le bug avec Python 3.11
diff --git a/fixKnowledge/nginx-conf-d-not-loaded.md b/fixKnowledge/nginx-conf-d-not-loaded.md
new file mode 100644
index 0000000..27425ec
--- /dev/null
+++ b/fixKnowledge/nginx-conf-d-not-loaded.md
@@ -0,0 +1,57 @@
+# Problème : nginx ne charge pas les configurations dans conf.d
+
+## Date
+2025-12-28
+
+## Problème
+Le site https://zapwall.fr/ pointait vers le mauvais site (LEcoffre au lieu de Zapwall) malgré une configuration correcte dans `/etc/nginx/conf.d/zapwall.fr.conf`.
+
+## Symptômes
+- https://zapwall.fr/ servait le site LEcoffre (test-lecoffreio.4nkweb.com)
+- La configuration `zapwall.fr.conf` existait et était correcte
+- Le service zapwall fonctionnait correctement sur le port 3001
+- Nginx ne chargeait pas les configurations dans `conf.d/`
+
+## Root cause
+La configuration principale nginx (`/etc/nginx/nginx.conf` dans le conteneur Docker `lecoffre_nginx_test`) ne contenait pas la directive `include /etc/nginx/conf.d/*.conf;` pour charger les configurations des sites dans le répertoire `conf.d/`.
+
+Le fichier nginx.conf était monté depuis l'hôte (`/home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/nginx-test.conf`) et ne chargeait que la configuration pour `test-lecoffreio.4nkweb.com`.
+
+## Impact
+- Le site zapwall.fr n'était pas accessible correctement
+- Les utilisateurs voyaient le mauvais site
+- La configuration zapwall.fr.conf était ignorée par nginx
+
+## Correctifs
+1. Ajout de la directive `include /etc/nginx/conf.d/*.conf;` dans le bloc `http` de nginx.conf, avant la fermeture du bloc
+2. Modification du fichier sur l'hôte : `/home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/nginx-test.conf`
+3. Redémarrage du conteneur nginx pour prendre en compte la modification
+
+## Modifications
+- **Fichier modifié** : `/home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/nginx-test.conf` (sur l'hôte)
+- **Ajout** :
+ ```nginx
+ # Include site configurations
+ include /etc/nginx/conf.d/*.conf;
+ ```
+ Avant la fermeture du bloc `http` (avant `}`)
+
+## Modalités de déploiement
+1. Arrêter le conteneur nginx : `sudo docker stop lecoffre_nginx_test`
+2. Modifier le fichier sur l'hôte : `/home/debian/sites/test-lecoffreio.4nkweb.com/deploy/nginx/nginx-test.conf`
+3. Ajouter l'inclusion avant la fermeture du bloc `http`
+4. Démarrer le conteneur : `sudo docker start lecoffre_nginx_test`
+5. Vérifier la configuration : `sudo docker exec lecoffre_nginx_test nginx -t`
+6. Vérifier que zapwall.fr est chargé : `sudo docker exec lecoffre_nginx_test nginx -T | grep "server_name zapwall.fr"`
+
+## Modalités d'analyse
+Pour vérifier si le problème existe :
+1. Vérifier si l'inclusion existe : `sudo docker exec lecoffre_nginx_test cat /etc/nginx/nginx.conf | grep "include.*conf.d"`
+2. Vérifier si zapwall.fr est chargé : `sudo docker exec lecoffre_nginx_test nginx -T | grep "server_name zapwall.fr"`
+3. Tester avec curl : `sudo docker exec lecoffre_nginx_test curl -s -k -H "Host: zapwall.fr" https://localhost | head -5`
+
+## Notes
+- Le fichier nginx.conf est monté en lecture seule depuis l'hôte dans le conteneur Docker
+- Les modifications doivent être faites sur l'hôte, pas dans le conteneur
+- Le conteneur doit être redémarré pour prendre en compte les modifications du fichier monté
+- L'inclusion doit être dans le bloc `http`, pas après sa fermeture
diff --git a/hooks/useNostrAuth.ts b/hooks/useNostrAuth.ts
index d160b8d..2f91861 100644
--- a/hooks/useNostrAuth.ts
+++ b/hooks/useNostrAuth.ts
@@ -6,12 +6,16 @@ export function useNostrAuth() {
const [state, setState] = useState(nostrAuthService.getState())
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
+ const [accountExists, setAccountExists] = useState(null)
useEffect(() => {
const unsubscribe = nostrAuthService.subscribe((newState) => {
setState(newState)
})
+ // Check if account exists on mount
+ nostrAuthService.accountExists().then(setAccountExists).catch(() => setAccountExists(false))
+
return unsubscribe
}, [])
@@ -44,5 +48,7 @@ export function useNostrAuth() {
error,
connect,
disconnect,
+ accountExists,
+ isUnlocked: nostrAuthService.isUnlocked(),
}
}
diff --git a/lib/articleEncryption.ts b/lib/articleEncryption.ts
index 5652797..9484b2b 100644
--- a/lib/articleEncryption.ts
+++ b/lib/articleEncryption.ts
@@ -58,6 +58,21 @@ function arrayBufferToHex(buffer: ArrayBuffer): string {
* Encrypt article content with AES-GCM
* Returns encrypted content, IV, and the encryption key
*/
+async function prepareEncryptionKey(key: string): Promise {
+ const keyBuffer = hexToArrayBuffer(key)
+ return crypto.subtle.importKey('raw', keyBuffer, { name: 'AES-GCM' }, false, ['encrypt'])
+}
+
+function prepareIV(iv: Uint8Array): { view: Uint8Array; buffer: ArrayBuffer } {
+ const ivBuffer = new ArrayBuffer(iv.byteLength)
+ const ivView = new Uint8Array(ivBuffer)
+ // Copy bytes from original IV
+ for (let i = 0; i < iv.length; i++) {
+ ivView[i] = iv[i] ?? 0
+ }
+ return { view: ivView, buffer: ivBuffer }
+}
+
export async function encryptArticleContent(content: string): Promise<{
encryptedContent: string
key: string
@@ -65,39 +80,21 @@ export async function encryptArticleContent(content: string): Promise<{
}> {
const key = generateEncryptionKey()
const iv = generateIV()
- const keyBuffer = hexToArrayBuffer(key)
-
- const cryptoKey = await crypto.subtle.importKey(
- 'raw',
- keyBuffer,
- { name: 'AES-GCM' },
- false,
- ['encrypt']
- )
-
+ const cryptoKey = await prepareEncryptionKey(key)
const encoder = new TextEncoder()
const encodedContent = encoder.encode(content)
-
- const ivBuffer = iv.buffer instanceof ArrayBuffer ? iv.buffer : new ArrayBuffer(iv.byteLength)
- const ivView = new Uint8Array(ivBuffer, 0, iv.byteLength)
- ivView.set(iv)
+ const { view: ivView, buffer: ivBuffer } = prepareIV(iv)
const encryptedBuffer = await crypto.subtle.encrypt(
- {
- name: 'AES-GCM',
- iv: ivView,
- },
+ { name: 'AES-GCM', iv: ivView as Uint8Array },
cryptoKey,
encodedContent
)
- const encryptedContent = arrayBufferToHex(encryptedBuffer)
- const ivHex = arrayBufferToHex(ivView.buffer)
-
return {
- encryptedContent,
+ encryptedContent: arrayBufferToHex(encryptedBuffer),
key,
- iv: ivHex,
+ iv: arrayBufferToHex(ivBuffer),
}
}
diff --git a/lib/articleMutations.ts b/lib/articleMutations.ts
index c6e1ae6..c0e97aa 100644
--- a/lib/articleMutations.ts
+++ b/lib/articleMutations.ts
@@ -163,6 +163,48 @@ function buildReviewEvent(
}
}
+function buildUpdateTags(draft: ArticleDraft, originalArticleId: string, newCategory: 'sciencefiction' | 'research') {
+ const updateTags = buildTags({
+ type: 'publication',
+ category: newCategory,
+ id: '', // Will be set to event.id after publication
+ paywall: true,
+ title: draft.title,
+ preview: draft.preview,
+ zapAmount: draft.zapAmount,
+ ...(draft.seriesId ? { seriesId: draft.seriesId } : {}),
+ ...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}),
+ })
+ updateTags.push(['e', originalArticleId], ['replace', 'article-update'])
+ return updateTags
+}
+
+async function publishUpdate(
+ draft: ArticleDraft,
+ authorPubkey: string,
+ originalArticleId: string
+): Promise {
+ const category = draft.category
+ requireCategory(category)
+ const presentationId = await ensurePresentation(authorPubkey)
+ const invoice = await createArticleInvoice(draft)
+ const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
+ const updateTags = buildUpdateTags(draft, originalArticleId, newCategory)
+
+ const publishedEvent = await publishPreviewWithInvoice(draft, invoice, presentationId, updateTags)
+ if (!publishedEvent) {
+ return updateFailure(originalArticleId, 'Failed to publish article update')
+ }
+ await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice)
+ return {
+ articleId: publishedEvent.id,
+ previewEventId: publishedEvent.id,
+ invoice,
+ success: true,
+ originalArticleId,
+ }
+}
+
export async function publishArticleUpdate(
originalArticleId: string,
draft: ArticleDraft,
@@ -171,41 +213,7 @@ export async function publishArticleUpdate(
): Promise {
try {
ensureKeys(authorPubkey, authorPrivateKey)
- const category = draft.category
- requireCategory(category)
- const presentationId = await ensurePresentation(authorPubkey)
- const invoice = await createArticleInvoice(draft)
- // Map category to new system
- const newCategory = category === 'science-fiction' ? 'sciencefiction' : 'research'
-
- // Build tags using new system (update event references original)
- const updateTags = buildTags({
- type: 'publication',
- category: newCategory,
- id: '', // Will be set to event.id after publication
- paywall: true,
- title: draft.title,
- preview: draft.preview,
- zapAmount: draft.zapAmount,
- ...(draft.seriesId ? { seriesId: draft.seriesId } : {}),
- ...(draft.bannerUrl ? { bannerUrl: draft.bannerUrl } : {}),
- })
-
- // Add reference to original article
- updateTags.push(['e', originalArticleId], ['replace', 'article-update'])
-
- const publishedEvent = await publishPreviewWithInvoice(draft, invoice, presentationId, updateTags)
- if (!publishedEvent) {
- return updateFailure(originalArticleId, 'Failed to publish article update')
- }
- await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice)
- return {
- articleId: publishedEvent.id,
- previewEventId: publishedEvent.id,
- invoice,
- success: true,
- originalArticleId,
- }
+ return await publishUpdate(draft, authorPubkey, originalArticleId)
} catch (error) {
return updateFailure(originalArticleId, error instanceof Error ? error.message : 'Unknown error')
}
diff --git a/lib/articlePublisher.ts b/lib/articlePublisher.ts
index d1f751b..a452fed 100644
--- a/lib/articlePublisher.ts
+++ b/lib/articlePublisher.ts
@@ -1,48 +1,13 @@
import { nostrService } from './nostr'
-import type { AlbyInvoice } from '@/types/alby'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
-import type { MediaRef } from '@/types/nostr'
-import {
- storePrivateContent,
- getStoredPrivateContent,
- getStoredInvoice,
- removeStoredPrivateContent,
-} from './articleStorage'
-import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
+import type { AlbyInvoice } from '@/types/alby'
+import { getStoredPrivateContent, getStoredInvoice, removeStoredPrivateContent } from './articleStorage'
import { buildPresentationEvent, fetchAuthorPresentationFromPool, sendEncryptedContent } from './articlePublisherHelpers'
-import {
- encryptArticleContent,
- encryptDecryptionKey,
-} from './articleEncryption'
+import type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes'
+import { prepareAuthorKeys, isValidCategory, type PublishValidationResult } from './articlePublisherValidation'
+import { buildFailure, encryptAndPublish } from './articlePublisherPublish'
-export interface ArticleDraft {
- title: string
- preview: string
- content: string // Full content that will be sent as private message after payment
- zapAmount: number
- category?: 'science-fiction' | 'scientific-research'
- seriesId?: string
- bannerUrl?: string
- media?: MediaRef[]
-}
-
-export interface AuthorPresentationDraft {
- title: string
- preview: string
- content: string
- presentation: string
- contentDescription: string
- mainnetAddress: string
- pictureUrl?: string | undefined
-}
-
-export interface PublishedArticle {
- articleId: string
- previewEventId: string
- invoice?: AlbyInvoice // Invoice created by author (required if success)
- success: boolean
- error?: string
-}
+export type { ArticleDraft, AuthorPresentationDraft, PublishedArticle } from './articlePublisherTypes'
/**
* Service for publishing articles on Nostr
@@ -51,61 +16,39 @@ export interface PublishedArticle {
export class ArticlePublisher {
// Removed unused siteTag - using new tag system instead
- private buildFailure(error?: string): PublishedArticle {
- const base: PublishedArticle = {
- articleId: '',
- previewEventId: '',
- success: false,
- }
- return error ? { ...base, error } : base
- }
-
- private prepareAuthorKeys(authorPubkey: string, authorPrivateKey?: string): { success: boolean; error?: string } {
- nostrService.setPublicKey(authorPubkey)
-
- if (authorPrivateKey) {
- nostrService.setPrivateKey(authorPrivateKey)
- return { success: true }
+ private async validatePublishRequest(
+ draft: ArticleDraft,
+ authorPubkey: string,
+ authorPrivateKey?: string
+ ): Promise {
+ const keySetup = prepareAuthorKeys(authorPubkey, authorPrivateKey)
+ if (!keySetup.success) {
+ return { success: false, error: keySetup.error ?? 'Key setup failed' }
}
- const existingPrivateKey = nostrService.getPrivateKey()
- if (!existingPrivateKey) {
+ const authorPrivateKeyForEncryption = authorPrivateKey ?? nostrService.getPrivateKey()
+ if (!authorPrivateKeyForEncryption) {
+ return { success: false, error: 'Private key required for encryption' }
+ }
+
+ const presentation = await this.getAuthorPresentation(authorPubkey)
+ if (!presentation) {
+ return { success: false, error: 'Vous devez créer un article de présentation avant de publier des articles.' }
+ }
+
+ if (!isValidCategory(draft.category)) {
+ return { success: false, error: 'Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).' }
+ }
+
+ const expectedAmount = 800
+ if (draft.zapAmount !== expectedAmount) {
return {
success: false,
- error:
- 'Private key required for signing. Please connect with a Nostr wallet that provides signing capabilities.',
+ error: `Invalid zap amount: ${draft.zapAmount} sats. Expected ${expectedAmount} sats (700 to author, 100 commission)`,
}
}
- return { success: true }
- }
-
- private isValidCategory(category?: ArticleDraft['category']): category is NonNullable {
- return category === 'science-fiction' || category === 'scientific-research'
- }
-
- private async publishPreview(
- draft: ArticleDraft,
- invoice: AlbyInvoice,
- presentationId: string,
- extraTags?: string[][],
- encryptedContent?: string,
- encryptedKey?: string
- ): Promise {
- const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags, encryptedContent, encryptedKey)
- const publishedEvent = await nostrService.publishEvent(previewEvent)
- return publishedEvent ?? null
- }
-
- private buildArticleExtraTags(draft: ArticleDraft, _category: NonNullable): string[][] {
- // Media tags are still supported in the new system
- const extraTags: string[][] = []
- if (draft.media && draft.media.length > 0) {
- draft.media.forEach((m) => {
- extraTags.push(['media', m.url, m.type])
- })
- }
- return extraTags
+ return { success: true, authorPrivateKeyForEncryption, category: draft.category }
}
/**
@@ -119,67 +62,20 @@ export class ArticlePublisher {
authorPrivateKey?: string
): Promise {
try {
- const keySetup = this.prepareAuthorKeys(authorPubkey, authorPrivateKey)
- if (!keySetup.success) {
- return this.buildFailure(keySetup.error)
- }
-
- const authorPrivateKeyForEncryption = authorPrivateKey ?? nostrService.getPrivateKey()
- if (!authorPrivateKeyForEncryption) {
- return this.buildFailure('Private key required for encryption')
+ const validation = await this.validatePublishRequest(draft, authorPubkey, authorPrivateKey)
+ if (!validation.success) {
+ return buildFailure(validation.error)
}
const presentation = await this.getAuthorPresentation(authorPubkey)
if (!presentation) {
- return this.buildFailure('Vous devez créer un article de présentation avant de publier des articles.')
+ return buildFailure('Presentation not found')
}
- if (!this.isValidCategory(draft.category)) {
- return this.buildFailure('Vous devez sélectionner une catégorie (science-fiction ou recherche scientifique).')
- }
- const category = draft.category
-
- // Verify zap amount matches expected commission structure
- const expectedAmount = 800 // PLATFORM_COMMISSIONS.article.total
- if (draft.zapAmount !== expectedAmount) {
- return this.buildFailure(
- `Invalid zap amount: ${draft.zapAmount} sats. Expected ${expectedAmount} sats (700 to author, 100 commission)`
- )
- }
-
- // Encrypt the article content
- const { encryptedContent, key, iv } = await encryptArticleContent(draft.content)
-
- // Encrypt the decryption key with the author's public key (for storage in tags)
- const encryptedKey = await encryptDecryptionKey(key, iv, authorPrivateKeyForEncryption, authorPubkey)
-
- const invoice = await createArticleInvoice(draft)
- const extraTags = this.buildArticleExtraTags(draft, category)
- const publishedEvent = await this.publishPreview(
- draft,
- invoice,
- presentation.id,
- extraTags,
- encryptedContent,
- encryptedKey
- )
- if (!publishedEvent) {
- return this.buildFailure('Failed to publish article')
- }
-
- // Store the decryption key locally for sending after payment
- await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice, key, iv)
-
- console.log('Article published with encrypted content', {
- articleId: publishedEvent.id,
- authorPubkey,
- timestamp: new Date().toISOString(),
- })
-
- return { articleId: publishedEvent.id, previewEventId: publishedEvent.id, invoice, success: true }
+ return await encryptAndPublish(draft, authorPubkey, validation.authorPrivateKeyForEncryption, validation.category, presentation.id)
} catch (error) {
console.error('Error publishing article:', error)
- return this.buildFailure(error instanceof Error ? error.message : 'Unknown error')
+ return buildFailure(error instanceof Error ? error.message : 'Unknown error')
}
}
@@ -208,6 +104,25 @@ export class ArticlePublisher {
* Send private content to a user after payment confirmation
* Returns detailed result with message event ID and verification status
*/
+ private logSendResult(result: import('./articlePublisherHelpers').SendContentResult, articleId: string, recipientPubkey: string) {
+ if (result.success) {
+ console.log('Private content sent successfully', {
+ articleId,
+ recipientPubkey,
+ messageEventId: result.messageEventId,
+ verified: result.verified,
+ timestamp: new Date().toISOString(),
+ })
+ } else {
+ console.error('Failed to send private content', {
+ articleId,
+ recipientPubkey,
+ error: result.error,
+ timestamp: new Date().toISOString(),
+ })
+ }
+ }
+
async sendPrivateContent(
articleId: string,
recipientPubkey: string,
@@ -218,31 +133,11 @@ export class ArticlePublisher {
if (!stored) {
const error = 'Private content not found for article'
console.error(error, { articleId, recipientPubkey })
- return {
- success: false,
- error,
- }
+ return { success: false, error }
}
const result = await sendEncryptedContent(articleId, recipientPubkey, stored, authorPrivateKey)
-
- if (result.success) {
- console.log('Private content sent successfully', {
- articleId,
- recipientPubkey,
- messageEventId: result.messageEventId,
- verified: result.verified,
- timestamp: new Date().toISOString(),
- })
- } else {
- console.error('Failed to send private content', {
- articleId,
- recipientPubkey,
- error: result.error,
- timestamp: new Date().toISOString(),
- })
- }
-
+ this.logSendResult(result, articleId, recipientPubkey)
return result
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
@@ -252,10 +147,7 @@ export class ArticlePublisher {
error: errorMessage,
timestamp: new Date().toISOString(),
})
- return {
- success: false,
- error: errorMessage,
- }
+ return { success: false, error: errorMessage }
}
}
@@ -280,11 +172,11 @@ export class ArticlePublisher {
nostrService.setPrivateKey(authorPrivateKey)
// Generate event ID before building event (using a temporary ID that will be replaced by Nostr)
- const tempEventId = 'temp_' + Math.random().toString(36).substring(7)
+ const tempEventId = `temp_${Math.random().toString(36).substring(7)}`
const publishedEvent = await nostrService.publishEvent(buildPresentationEvent(draft, tempEventId, 'sciencefiction'))
if (!publishedEvent) {
- return this.buildFailure('Failed to publish presentation article')
+ return buildFailure('Failed to publish presentation article')
}
return {
@@ -294,7 +186,7 @@ export class ArticlePublisher {
}
} catch (error) {
console.error('Error publishing presentation article:', error)
- return this.buildFailure(error instanceof Error ? error.message : 'Unknown error')
+ return buildFailure(error instanceof Error ? error.message : 'Unknown error')
}
}
diff --git a/lib/articlePublisherHelpers.ts b/lib/articlePublisherHelpers.ts
index 90590f9..47d52f0 100644
--- a/lib/articlePublisherHelpers.ts
+++ b/lib/articlePublisherHelpers.ts
@@ -1,271 +1,3 @@
-import { nip04, type Event } from 'nostr-tools'
-import { nostrService } from './nostr'
-import type { AuthorPresentationDraft } from './articlePublisher'
-import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
-import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem'
-
-const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
-
-export function buildPresentationEvent(draft: AuthorPresentationDraft, eventId: string, category: 'sciencefiction' | 'research' = 'sciencefiction') {
- return {
- kind: 1 as const,
- created_at: Math.floor(Date.now() / 1000),
- tags: buildTags({
- type: 'author',
- category,
- id: eventId,
- paywall: false,
- title: draft.title,
- preview: draft.preview,
- mainnetAddress: draft.mainnetAddress,
- totalSponsoring: 0,
- ...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}),
- }),
- content: draft.content,
- }
-}
-
-export function parsePresentationEvent(event: Event): import('@/types/nostr').AuthorPresentationArticle | null {
- const tags = extractTagsFromEvent(event)
-
- // Check if it's an author type (tag is 'author' in English)
- if (tags.type !== 'author') {
- return null
- }
-
- return {
- id: tags.id ?? event.id,
- pubkey: event.pubkey,
- title: (tags.title as string | undefined) ?? 'Présentation',
- preview: (tags.preview as string | undefined) ?? event.content.substring(0, 200),
- content: event.content,
- createdAt: event.created_at,
- zapAmount: 0,
- paid: true,
- category: 'author-presentation',
- isPresentation: true,
- mainnetAddress: (tags.mainnetAddress as string | undefined) ?? '',
- totalSponsoring: (tags.totalSponsoring as number | undefined) ?? 0,
- ...(tags.pictureUrl ? { bannerUrl: tags.pictureUrl as string } : {}),
- }
-}
-
-export function fetchAuthorPresentationFromPool(
- pool: SimplePoolWithSub,
- pubkey: string
-): Promise {
- const filters = [
- {
- ...buildTagFilter({
- type: 'author',
- authorPubkey: pubkey,
- }),
- limit: 1,
- },
- ]
-
- return new Promise((resolve) => {
- let resolved = false
- const sub = pool.sub([RELAY_URL], filters)
-
- const finalize = (value: import('@/types/nostr').AuthorPresentationArticle | null) => {
- if (resolved) {
- return
- }
- resolved = true
- sub.unsub()
- resolve(value)
- }
-
- sub.on('event', (event: Event) => {
- const parsed = parsePresentationEvent(event)
- if (parsed) {
- finalize(parsed)
- }
- })
-
- sub.on('eose', () => finalize(null))
- setTimeout(() => finalize(null), 5000)
- })
-}
-
-export interface SendContentResult {
- success: boolean
- messageEventId?: string
- error?: string
- verified?: boolean
-}
-
-export async function sendEncryptedContent(
- articleId: string,
- recipientPubkey: string,
- storedContent: { content: string; authorPubkey: string; decryptionKey?: string; decryptionIV?: string },
- authorPrivateKey: string
-): Promise {
- try {
- nostrService.setPrivateKey(authorPrivateKey)
- nostrService.setPublicKey(storedContent.authorPubkey)
-
- // Send the decryption key instead of the full content
- // The key is sent as JSON: { key: string, iv: string }
- const keyData = storedContent.decryptionKey && storedContent.decryptionIV
- ? JSON.stringify({ key: storedContent.decryptionKey, iv: storedContent.decryptionIV })
- : storedContent.content // Fallback to old behavior if keys are not available
-
- const encryptedKey = await Promise.resolve(nip04.encrypt(authorPrivateKey, recipientPubkey, keyData))
-
- const privateMessageEvent = {
- kind: 4,
- created_at: Math.floor(Date.now() / 1000),
- tags: [
- ['p', recipientPubkey],
- ['e', articleId],
- ],
- content: encryptedKey,
- }
-
- const publishedEvent = await nostrService.publishEvent(privateMessageEvent)
-
- if (!publishedEvent) {
- console.error('Failed to publish private message event', {
- articleId,
- recipientPubkey,
- authorPubkey: storedContent.authorPubkey,
- })
- return {
- success: false,
- error: 'Failed to publish private message event',
- }
- }
-
- const messageEventId = publishedEvent.id
- console.log('Private message published', {
- messageEventId,
- articleId,
- recipientPubkey,
- authorPubkey: storedContent.authorPubkey,
- timestamp: new Date().toISOString(),
- })
-
- const verified = await verifyPrivateMessagePublished(messageEventId, storedContent.authorPubkey, recipientPubkey, articleId)
-
- if (verified) {
- console.log('Private message verified on relay', {
- messageEventId,
- articleId,
- recipientPubkey,
- })
- } else {
- console.warn('Private message published but not yet verified on relay', {
- messageEventId,
- articleId,
- recipientPubkey,
- })
- }
-
- return {
- success: true,
- messageEventId,
- verified,
- }
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : 'Unknown error'
- console.error('Error sending encrypted content', {
- articleId,
- recipientPubkey,
- authorPubkey: storedContent.authorPubkey,
- error: errorMessage,
- timestamp: new Date().toISOString(),
- })
- return {
- success: false,
- error: errorMessage,
- }
- }
-}
-
-async function verifyPrivateMessagePublished(
- messageEventId: string,
- authorPubkey: string,
- recipientPubkey: string,
- articleId: string
-): Promise {
- try {
- const pool = nostrService.getPool()
- if (!pool) {
- console.error('Pool not initialized for message verification', {
- messageEventId,
- articleId,
- recipientPubkey,
- })
- return false
- }
-
- return new Promise((resolve) => {
- let resolved = false
- const filters = [
- {
- kinds: [4],
- ids: [messageEventId],
- authors: [authorPubkey],
- '#p': [recipientPubkey],
- '#e': [articleId],
- limit: 1,
- },
- ]
-
- const sub = (pool as import('@/types/nostr-tools-extended').SimplePoolWithSub).sub([RELAY_URL], filters)
-
- const finalize = (value: boolean) => {
- if (resolved) {
- return
- }
- resolved = true
- sub.unsub()
- resolve(value)
- }
-
- sub.on('event', (event) => {
- console.log('Private message verified on relay', {
- messageEventId: event.id,
- articleId,
- recipientPubkey,
- authorPubkey,
- timestamp: new Date().toISOString(),
- })
- finalize(true)
- })
-
- sub.on('eose', () => {
- console.warn('Private message not found on relay after EOSE', {
- messageEventId,
- articleId,
- recipientPubkey,
- timestamp: new Date().toISOString(),
- })
- finalize(false)
- })
-
- setTimeout(() => {
- if (!resolved) {
- console.warn('Timeout verifying private message on relay', {
- messageEventId,
- articleId,
- recipientPubkey,
- timestamp: new Date().toISOString(),
- })
- finalize(false)
- }
- }, 5000)
- })
- } catch (error) {
- console.error('Error verifying private message', {
- messageEventId,
- articleId,
- recipientPubkey,
- error: error instanceof Error ? error.message : 'Unknown error',
- timestamp: new Date().toISOString(),
- })
- return false
- }
-}
+export { buildPresentationEvent, parsePresentationEvent, fetchAuthorPresentationFromPool } from './articlePublisherHelpersPresentation'
+export { sendEncryptedContent, type SendContentResult } from './articlePublisherHelpersEncryption'
+export { verifyPrivateMessagePublished } from './articlePublisherHelpersVerification'
diff --git a/lib/articlePublisherHelpersEncryption.ts b/lib/articlePublisherHelpersEncryption.ts
new file mode 100644
index 0000000..04bebdb
--- /dev/null
+++ b/lib/articlePublisherHelpersEncryption.ts
@@ -0,0 +1,86 @@
+import { nip04 } from 'nostr-tools'
+import { nostrService } from './nostr'
+import { publishAndVerifyMessage } from './articlePublisherHelpersVerification'
+
+export interface SendContentResult {
+ success: boolean
+ messageEventId?: string
+ error?: string
+ verified?: boolean
+}
+
+export function prepareKeyData(storedContent: { content: string; decryptionKey?: string; decryptionIV?: string }): string {
+ if (storedContent.decryptionKey && storedContent.decryptionIV) {
+ return JSON.stringify({ key: storedContent.decryptionKey, iv: storedContent.decryptionIV })
+ }
+ return storedContent.content // Fallback to old behavior if keys are not available
+}
+
+export function buildPrivateMessageEvent(recipientPubkey: string, articleId: string, encryptedKey: string) {
+ return {
+ kind: 4,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [
+ ['p', recipientPubkey],
+ ['e', articleId],
+ ],
+ content: encryptedKey,
+ }
+}
+
+async function publishEncryptedMessage(
+ articleId: string,
+ recipientPubkey: string,
+ authorPubkey: string,
+ authorPrivateKey: string,
+ keyData: string
+): Promise<{ eventId: string } | null> {
+ const encryptedKey = await Promise.resolve(nip04.encrypt(authorPrivateKey, recipientPubkey, keyData))
+ const privateMessageEvent = buildPrivateMessageEvent(recipientPubkey, articleId, encryptedKey)
+ const publishedEvent = await nostrService.publishEvent(privateMessageEvent)
+
+ if (!publishedEvent) {
+ console.error('Failed to publish private message event', { articleId, recipientPubkey, authorPubkey })
+ return null
+ }
+
+ console.log('Private message published', {
+ messageEventId: publishedEvent.id,
+ articleId,
+ recipientPubkey,
+ authorPubkey,
+ timestamp: new Date().toISOString(),
+ })
+ return { eventId: publishedEvent.id }
+}
+
+export async function sendEncryptedContent(
+ articleId: string,
+ recipientPubkey: string,
+ storedContent: { content: string; authorPubkey: string; decryptionKey?: string; decryptionIV?: string },
+ authorPrivateKey: string
+): Promise {
+ try {
+ nostrService.setPrivateKey(authorPrivateKey)
+ nostrService.setPublicKey(storedContent.authorPubkey)
+
+ const keyData = prepareKeyData(storedContent)
+ const publishResult = await publishEncryptedMessage(articleId, recipientPubkey, storedContent.authorPubkey, authorPrivateKey, keyData)
+ if (!publishResult) {
+ return { success: false, error: 'Failed to publish private message event' }
+ }
+
+ const verified = await publishAndVerifyMessage(articleId, recipientPubkey, storedContent.authorPubkey, publishResult.eventId)
+ return { success: true, messageEventId: publishResult.eventId, verified }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error'
+ console.error('Error sending encrypted content', {
+ articleId,
+ recipientPubkey,
+ authorPubkey: storedContent.authorPubkey,
+ error: errorMessage,
+ timestamp: new Date().toISOString(),
+ })
+ return { success: false, error: errorMessage }
+ }
+}
diff --git a/lib/articlePublisherHelpersPresentation.ts b/lib/articlePublisherHelpersPresentation.ts
new file mode 100644
index 0000000..979d229
--- /dev/null
+++ b/lib/articlePublisherHelpersPresentation.ts
@@ -0,0 +1,89 @@
+import { type Event } from 'nostr-tools'
+import type { AuthorPresentationDraft } from './articlePublisher'
+import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
+import { buildTags, extractTagsFromEvent, buildTagFilter } from './nostrTagSystem'
+import { getPrimaryRelaySync } from './config'
+
+export function buildPresentationEvent(draft: AuthorPresentationDraft, eventId: string, category: 'sciencefiction' | 'research' = 'sciencefiction') {
+ return {
+ kind: 1 as const,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: buildTags({
+ type: 'author',
+ category,
+ id: eventId,
+ paywall: false,
+ title: draft.title,
+ preview: draft.preview,
+ mainnetAddress: draft.mainnetAddress,
+ totalSponsoring: 0,
+ ...(draft.pictureUrl ? { pictureUrl: draft.pictureUrl } : {}),
+ }),
+ content: draft.content,
+ }
+}
+
+export function parsePresentationEvent(event: Event): import('@/types/nostr').AuthorPresentationArticle | null {
+ const tags = extractTagsFromEvent(event)
+
+ // Check if it's an author type (tag is 'author' in English)
+ if (tags.type !== 'author') {
+ return null
+ }
+
+ return {
+ id: tags.id ?? event.id,
+ pubkey: event.pubkey,
+ title: tags.title ?? 'Présentation',
+ preview: tags.preview ?? event.content.substring(0, 200),
+ content: event.content,
+ createdAt: event.created_at,
+ zapAmount: 0,
+ paid: true,
+ category: 'author-presentation',
+ isPresentation: true,
+ mainnetAddress: tags.mainnetAddress ?? '',
+ totalSponsoring: tags.totalSponsoring ?? 0,
+ ...(tags.pictureUrl !== undefined && tags.pictureUrl !== null && typeof tags.pictureUrl === 'string' ? { bannerUrl: tags.pictureUrl } : {}),
+ }
+}
+
+export function fetchAuthorPresentationFromPool(
+ pool: SimplePoolWithSub,
+ pubkey: string
+): Promise {
+ const filters = [
+ {
+ ...buildTagFilter({
+ type: 'author',
+ authorPubkey: pubkey,
+ }),
+ limit: 1,
+ },
+ ]
+
+ return new Promise((resolve) => {
+ let resolved = false
+ const relayUrl = getPrimaryRelaySync()
+ const sub = pool.sub([relayUrl], filters)
+
+ const finalize = (value: import('@/types/nostr').AuthorPresentationArticle | null) => {
+ if (resolved) {
+ return
+ }
+ resolved = true
+ sub.unsub()
+ resolve(value)
+ }
+
+ sub.on('event', (event: Event) => {
+ const parsed = parsePresentationEvent(event)
+ if (parsed) {
+ finalize(parsed)
+ }
+ })
+
+ sub.on('eose', () => finalize(null))
+ setTimeout(() => finalize(null), 5000)
+ })
+}
diff --git a/lib/articlePublisherHelpersVerification.ts b/lib/articlePublisherHelpersVerification.ts
new file mode 100644
index 0000000..918547b
--- /dev/null
+++ b/lib/articlePublisherHelpersVerification.ts
@@ -0,0 +1,156 @@
+import { nostrService } from './nostr'
+import { getPrimaryRelaySync } from './config'
+
+export function createMessageVerificationFilters(messageEventId: string, authorPubkey: string, recipientPubkey: string, articleId: string) {
+ return [
+ {
+ kinds: [4],
+ ids: [messageEventId],
+ authors: [authorPubkey],
+ '#p': [recipientPubkey],
+ '#e': [articleId],
+ limit: 1,
+ },
+ ]
+}
+
+export function handleMessageVerificationEvent(
+ event: import('nostr-tools').Event,
+ articleId: string,
+ recipientPubkey: string,
+ authorPubkey: string,
+ finalize: (value: boolean) => void
+): void {
+ console.log('Private message verified on relay', {
+ messageEventId: event.id,
+ articleId,
+ recipientPubkey,
+ authorPubkey,
+ timestamp: new Date().toISOString(),
+ })
+ finalize(true)
+}
+
+export function setupMessageVerificationHandlers(
+ sub: import('@/types/nostr-tools-extended').SimplePoolWithSub['sub'] extends (...args: any[]) => infer R ? R : never,
+ messageEventId: string,
+ articleId: string,
+ recipientPubkey: string,
+ authorPubkey: string,
+ finalize: (value: boolean) => void,
+ isResolved: () => boolean
+): void {
+ sub.on('event', (event) => {
+ handleMessageVerificationEvent(event, articleId, recipientPubkey, authorPubkey, finalize)
+ })
+
+ sub.on('eose', () => {
+ console.warn('Private message not found on relay after EOSE', {
+ messageEventId,
+ articleId,
+ recipientPubkey,
+ timestamp: new Date().toISOString(),
+ })
+ finalize(false)
+ })
+
+ setTimeout(() => {
+ if (!isResolved()) {
+ console.warn('Timeout verifying private message on relay', {
+ messageEventId,
+ articleId,
+ recipientPubkey,
+ timestamp: new Date().toISOString(),
+ })
+ finalize(false)
+ }
+ }, 5000)
+}
+
+function createMessageVerificationSubscription(
+ pool: import('@/types/nostr-tools-extended').SimplePoolWithSub,
+ messageEventId: string,
+ authorPubkey: string,
+ recipientPubkey: string,
+ articleId: string
+) {
+ const filters = createMessageVerificationFilters(messageEventId, authorPubkey, recipientPubkey, articleId)
+ const relayUrl = getPrimaryRelaySync()
+ return pool.sub([relayUrl], filters)
+}
+
+function createVerificationPromise(
+ sub: import('@/types/nostr-tools-extended').SimplePoolWithSub['sub'] extends (...args: any[]) => infer R ? R : never,
+ messageEventId: string,
+ articleId: string,
+ recipientPubkey: string,
+ authorPubkey: string
+): Promise {
+ return new Promise((resolve) => {
+ let resolved = false
+
+ const finalize = (value: boolean) => {
+ if (resolved) {
+ return
+ }
+ resolved = true
+ sub.unsub()
+ resolve(value)
+ }
+
+ setupMessageVerificationHandlers(sub, messageEventId, articleId, recipientPubkey, authorPubkey, finalize, () => resolved)
+ })
+}
+
+export function verifyPrivateMessagePublished(
+ messageEventId: string,
+ authorPubkey: string,
+ recipientPubkey: string,
+ articleId: string
+): Promise {
+ try {
+ const pool = nostrService.getPool()
+ if (!pool) {
+ console.error('Pool not initialized for message verification', {
+ messageEventId,
+ articleId,
+ recipientPubkey,
+ })
+ return Promise.resolve(false)
+ }
+
+ const sub = createMessageVerificationSubscription(
+ pool as import('@/types/nostr-tools-extended').SimplePoolWithSub,
+ messageEventId,
+ authorPubkey,
+ recipientPubkey,
+ articleId
+ )
+
+ return createVerificationPromise(sub, messageEventId, articleId, recipientPubkey, authorPubkey)
+ } catch (error) {
+ console.error('Error verifying private message', {
+ messageEventId,
+ articleId,
+ recipientPubkey,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ timestamp: new Date().toISOString(),
+ })
+ return Promise.resolve(false)
+ }
+}
+
+export async function publishAndVerifyMessage(
+ articleId: string,
+ recipientPubkey: string,
+ authorPubkey: string,
+ messageEventId: string
+): Promise {
+ const verified = await verifyPrivateMessagePublished(messageEventId, authorPubkey, recipientPubkey, articleId)
+ if (verified) {
+ console.log('Private message verified on relay', { messageEventId, articleId, recipientPubkey })
+ } else {
+ console.warn('Private message published but not yet verified on relay', { messageEventId, articleId, recipientPubkey })
+ }
+ return verified
+}
diff --git a/lib/articlePublisherPublish.ts b/lib/articlePublisherPublish.ts
new file mode 100644
index 0000000..bb3826d
--- /dev/null
+++ b/lib/articlePublisherPublish.ts
@@ -0,0 +1,66 @@
+import { nostrService } from './nostr'
+import type { ArticleDraft, PublishedArticle } from './articlePublisherTypes'
+import type { AlbyInvoice } from '@/types/alby'
+import { createArticleInvoice, createPreviewEvent } from './articleInvoice'
+import { encryptArticleContent, encryptDecryptionKey } from './articleEncryption'
+import { storePrivateContent } from './articleStorage'
+
+export function buildFailure(error?: string): PublishedArticle {
+ const base: PublishedArticle = {
+ articleId: '',
+ previewEventId: '',
+ success: false,
+ }
+ return error ? { ...base, error } : base
+}
+
+export async function publishPreview(
+ draft: ArticleDraft,
+ invoice: AlbyInvoice,
+ presentationId: string,
+ extraTags?: string[][],
+ encryptedContent?: string,
+ encryptedKey?: string
+): Promise {
+ const previewEvent = createPreviewEvent(draft, invoice, presentationId, extraTags, encryptedContent, encryptedKey)
+ const publishedEvent = await nostrService.publishEvent(previewEvent)
+ return publishedEvent ?? null
+}
+
+export function buildArticleExtraTags(draft: ArticleDraft, _category: NonNullable): string[][] {
+ // Media tags are still supported in the new system
+ const extraTags: string[][] = []
+ if (draft.media && draft.media.length > 0) {
+ draft.media.forEach((m) => {
+ extraTags.push(['media', m.url, m.type])
+ })
+ }
+ return extraTags
+}
+
+export async function encryptAndPublish(
+ draft: ArticleDraft,
+ authorPubkey: string,
+ authorPrivateKeyForEncryption: string,
+ category: NonNullable,
+ presentationId: string
+): Promise {
+ const { encryptedContent, key, iv } = await encryptArticleContent(draft.content)
+ const encryptedKey = await encryptDecryptionKey(key, iv, authorPrivateKeyForEncryption, authorPubkey)
+ const invoice = await createArticleInvoice(draft)
+ const extraTags = buildArticleExtraTags(draft, category)
+ const publishedEvent = await publishPreview(draft, invoice, presentationId, extraTags, encryptedContent, encryptedKey)
+
+ if (!publishedEvent) {
+ return buildFailure('Failed to publish article')
+ }
+
+ await storePrivateContent(publishedEvent.id, draft.content, authorPubkey, invoice, key, iv)
+ console.log('Article published with encrypted content', {
+ articleId: publishedEvent.id,
+ authorPubkey,
+ timestamp: new Date().toISOString(),
+ })
+
+ return { articleId: publishedEvent.id, previewEventId: publishedEvent.id, invoice, success: true }
+}
diff --git a/lib/articlePublisherTypes.ts b/lib/articlePublisherTypes.ts
new file mode 100644
index 0000000..181dd00
--- /dev/null
+++ b/lib/articlePublisherTypes.ts
@@ -0,0 +1,31 @@
+import type { AlbyInvoice } from '@/types/alby'
+import type { MediaRef } from '@/types/nostr'
+
+export interface ArticleDraft {
+ title: string
+ preview: string
+ content: string // Full content that will be sent as private message after payment
+ zapAmount: number
+ category?: 'science-fiction' | 'scientific-research'
+ seriesId?: string
+ bannerUrl?: string
+ media?: MediaRef[]
+}
+
+export interface AuthorPresentationDraft {
+ title: string
+ preview: string
+ content: string
+ presentation: string
+ contentDescription: string
+ mainnetAddress: string
+ pictureUrl?: string | undefined
+}
+
+export interface PublishedArticle {
+ articleId: string
+ previewEventId: string
+ invoice?: AlbyInvoice // Invoice created by author (required if success)
+ success: boolean
+ error?: string
+}
diff --git a/lib/articlePublisherValidation.ts b/lib/articlePublisherValidation.ts
new file mode 100644
index 0000000..b2f42b0
--- /dev/null
+++ b/lib/articlePublisherValidation.ts
@@ -0,0 +1,39 @@
+import { nostrService } from './nostr'
+import type { ArticleDraft } from './articlePublisherTypes'
+
+export function prepareAuthorKeys(authorPubkey: string, authorPrivateKey?: string): { success: boolean; error?: string } {
+ nostrService.setPublicKey(authorPubkey)
+
+ if (authorPrivateKey) {
+ nostrService.setPrivateKey(authorPrivateKey)
+ return { success: true }
+ }
+
+ const existingPrivateKey = nostrService.getPrivateKey()
+ if (!existingPrivateKey) {
+ return {
+ success: false,
+ error:
+ 'Private key required for signing. Please connect with a Nostr wallet that provides signing capabilities.',
+ }
+ }
+
+ return { success: true }
+}
+
+export function isValidCategory(category?: ArticleDraft['category']): category is NonNullable {
+ return category === 'science-fiction' || category === 'scientific-research'
+}
+
+export interface ValidationResult {
+ success: false
+ error: string
+}
+
+export interface ValidationSuccess {
+ success: true
+ authorPrivateKeyForEncryption: string
+ category: NonNullable
+}
+
+export type PublishValidationResult = ValidationResult | ValidationSuccess
diff --git a/lib/articleQueries.ts b/lib/articleQueries.ts
index 80241b7..aaf1154 100644
--- a/lib/articleQueries.ts
+++ b/lib/articleQueries.ts
@@ -4,15 +4,9 @@ import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import type { Article } from '@/types/nostr'
import { parseArticleFromEvent } from './nostrEventParsing'
import { buildTagFilter } from './nostrTagSystem'
+import { getPrimaryRelaySync } from './config'
-const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
-
-export function getArticlesBySeries(seriesId: string, timeoutMs: number = 5000, limit: number = 100): Promise {
- const pool = nostrService.getPool()
- if (!pool) {
- throw new Error('Pool not initialized')
- }
- const poolWithSub = pool as SimplePoolWithSub
+function createSeriesSubscription(poolWithSub: SimplePoolWithSub, seriesId: string, limit: number) {
const filters = [
{
...buildTagFilter({
@@ -22,10 +16,20 @@ export function getArticlesBySeries(seriesId: string, timeoutMs: number = 5000,
limit,
},
]
+ const relayUrl = getPrimaryRelaySync()
+ return poolWithSub.sub([relayUrl], filters)
+}
+
+export function getArticlesBySeries(seriesId: string, timeoutMs: number = 5000, limit: number = 100): Promise {
+ const pool = nostrService.getPool()
+ if (!pool) {
+ throw new Error('Pool not initialized')
+ }
+ const poolWithSub = pool as SimplePoolWithSub
+ const sub = createSeriesSubscription(poolWithSub, seriesId, limit)
return new Promise((resolve) => {
const results: Article[] = []
- const sub = poolWithSub.sub([RELAY_URL], filters)
let finished = false
const done = () => {
diff --git a/lib/automaticTransfer.ts b/lib/automaticTransfer.ts
index 7c6747c..4a94e12 100644
--- a/lib/automaticTransfer.ts
+++ b/lib/automaticTransfer.ts
@@ -22,6 +22,28 @@ export class AutomaticTransferService {
* Transfer author portion after article payment
* Creates a Lightning invoice from the platform to the author
*/
+ private logTransferRequired(type: 'article' | 'review', id: string, pubkey: string, amount: number, recipient: string, platformCommission: number) {
+ const logData = {
+ [type === 'article' ? 'articleId' : 'reviewId']: id,
+ [type === 'article' ? 'articlePubkey' : 'reviewerPubkey']: pubkey,
+ amount,
+ recipient,
+ platformCommission,
+ timestamp: new Date().toISOString(),
+ }
+ console.log(`Automatic transfer required${type === 'review' ? ' for review' : ''}`, logData)
+ }
+
+ private buildTransferError(error: unknown, recipient: string, amount: number = 0): TransferResult {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error'
+ return {
+ success: false,
+ error: errorMessage,
+ amount,
+ recipient,
+ }
+ }
+
async transferAuthorPortion(
authorLightningAddress: string,
articleId: string,
@@ -40,23 +62,8 @@ export class AutomaticTransferService {
}
}
- // In a real implementation, this would:
- // 1. Create a Lightning invoice from platform to author
- // 2. Pay the invoice automatically
- // 3. Track the transfer
-
- // For now, we log the transfer that should be made
- console.log('Automatic transfer required', {
- articleId,
- articlePubkey,
- amount: split.author,
- recipient: authorLightningAddress,
- platformCommission: split.platform,
- timestamp: new Date().toISOString(),
- })
-
- // Track the transfer requirement
- await this.trackTransferRequirement('article', articleId, articlePubkey, split.author, authorLightningAddress)
+ this.logTransferRequired('article', articleId, articlePubkey, split.author, authorLightningAddress, split.platform)
+ this.trackTransferRequirement('article', articleId, articlePubkey, split.author, authorLightningAddress)
return {
success: true,
@@ -64,19 +71,13 @@ export class AutomaticTransferService {
recipient: authorLightningAddress,
}
} catch (error) {
- const errorMessage = error instanceof Error ? error.message : 'Unknown error'
console.error('Error transferring author portion', {
articleId,
articlePubkey,
- error: errorMessage,
+ error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString(),
})
- return {
- success: false,
- error: errorMessage,
- amount: 0,
- recipient: authorLightningAddress,
- }
+ return this.buildTransferError(error, authorLightningAddress)
}
}
@@ -101,16 +102,8 @@ export class AutomaticTransferService {
}
}
- console.log('Automatic transfer required for review', {
- reviewId,
- reviewerPubkey,
- amount: split.reviewer,
- recipient: reviewerLightningAddress,
- platformCommission: split.platform,
- timestamp: new Date().toISOString(),
- })
-
- await this.trackTransferRequirement('review', reviewId, reviewerPubkey, split.reviewer, reviewerLightningAddress)
+ this.logTransferRequired('review', reviewId, reviewerPubkey, split.reviewer, reviewerLightningAddress, split.platform)
+ this.trackTransferRequirement('review', reviewId, reviewerPubkey, split.reviewer, reviewerLightningAddress)
return {
success: true,
@@ -118,19 +111,13 @@ export class AutomaticTransferService {
recipient: reviewerLightningAddress,
}
} catch (error) {
- const errorMessage = error instanceof Error ? error.message : 'Unknown error'
console.error('Error transferring reviewer portion', {
reviewId,
reviewerPubkey,
- error: errorMessage,
+ error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString(),
})
- return {
- success: false,
- error: errorMessage,
- amount: 0,
- recipient: reviewerLightningAddress,
- }
+ return this.buildTransferError(error, reviewerLightningAddress)
}
}
@@ -138,13 +125,13 @@ export class AutomaticTransferService {
* Track transfer requirement for later processing
* In production, this would be stored in a database or queue
*/
- private async trackTransferRequirement(
+ private trackTransferRequirement(
type: 'article' | 'review',
id: string,
recipientPubkey: string,
amount: number,
recipientAddress: string
- ): Promise {
+ ): void {
// In production, this would:
// 1. Store in a database/queue for processing
// 2. Trigger automatic transfer via platform's Lightning node
diff --git a/lib/config.ts b/lib/config.ts
new file mode 100644
index 0000000..3b4e208
--- /dev/null
+++ b/lib/config.ts
@@ -0,0 +1,97 @@
+/**
+ * Configuration utilities
+ * Provides access to Nostr relays, NIP-95 APIs, and platform settings
+ * All values are stored in IndexedDB with hardcoded defaults
+ */
+
+import { configStorage, getPrimaryRelaySync, getPrimaryNip95ApiSync, getPlatformLightningAddressSync } from './configStorage'
+
+/**
+ * Get primary relay URL
+ * Uses IndexedDB if available, otherwise returns default
+ */
+export async function getPrimaryRelay(): Promise {
+ if (typeof window === 'undefined') {
+ return getPrimaryRelaySync()
+ }
+
+ try {
+ return await configStorage.getPrimaryRelay()
+ } catch (error) {
+ console.error('Error getting primary relay from IndexedDB:', error)
+ return getPrimaryRelaySync()
+ }
+}
+
+/**
+ * Get all enabled relay URLs
+ * Uses IndexedDB if available, otherwise returns default
+ */
+export async function getEnabledRelays(): Promise {
+ if (typeof window === 'undefined') {
+ return [getPrimaryRelaySync()]
+ }
+
+ try {
+ return await configStorage.getEnabledRelays()
+ } catch (error) {
+ console.error('Error getting enabled relays from IndexedDB:', error)
+ return [getPrimaryRelaySync()]
+ }
+}
+
+/**
+ * Get primary NIP-95 API URL
+ * Uses IndexedDB if available, otherwise returns default
+ */
+export async function getPrimaryNip95Api(): Promise {
+ if (typeof window === 'undefined') {
+ return getPrimaryNip95ApiSync()
+ }
+
+ try {
+ return await configStorage.getPrimaryNip95Api()
+ } catch (error) {
+ console.error('Error getting primary NIP-95 API from IndexedDB:', error)
+ return getPrimaryNip95ApiSync()
+ }
+}
+
+/**
+ * Get all enabled NIP-95 API URLs
+ * Uses IndexedDB if available, otherwise returns default
+ */
+export async function getEnabledNip95Apis(): Promise {
+ if (typeof window === 'undefined') {
+ return [getPrimaryNip95ApiSync()]
+ }
+
+ try {
+ return await configStorage.getEnabledNip95Apis()
+ } catch (error) {
+ console.error('Error getting enabled NIP-95 APIs from IndexedDB:', error)
+ return [getPrimaryNip95ApiSync()]
+ }
+}
+
+/**
+ * Get platform Lightning address
+ * Uses IndexedDB if available, otherwise returns default
+ */
+export async function getPlatformLightningAddress(): Promise {
+ if (typeof window === 'undefined') {
+ return getPlatformLightningAddressSync()
+ }
+
+ try {
+ return await configStorage.getPlatformLightningAddress()
+ } catch (error) {
+ console.error('Error getting platform Lightning address from IndexedDB:', error)
+ return getPlatformLightningAddressSync()
+ }
+}
+
+/**
+ * Re-export synchronous getters from configStorage
+ */
+export { getPrimaryRelaySync, getPrimaryNip95ApiSync, getPlatformLightningAddressSync } from './configStorage'
diff --git a/lib/configStorage.ts b/lib/configStorage.ts
new file mode 100644
index 0000000..4deef65
--- /dev/null
+++ b/lib/configStorage.ts
@@ -0,0 +1,280 @@
+/**
+ * Configuration storage service using IndexedDB
+ * Stores Nostr relay URLs and NIP-95 API endpoints
+ */
+
+import type { ConfigData } from './configStorageTypes'
+import { DEFAULT_RELAYS, DEFAULT_NIP95_APIS, DEFAULT_PLATFORM_LIGHTNING_ADDRESS } from './configStorageTypes'
+import {
+ getEnabledRelays,
+ getPrimaryRelayFromConfig,
+ addRelayToConfig,
+ updateRelayInConfig,
+ removeRelayFromConfig,
+} from './configStorageRelays'
+import {
+ getEnabledNip95Apis,
+ getPrimaryNip95ApiFromConfig,
+ addNip95ApiToConfig,
+ updateNip95ApiInConfig,
+ removeNip95ApiFromConfig,
+} from './configStorageNip95'
+
+const DB_NAME = 'nostr_paywall_config'
+const DB_VERSION = 1
+const STORE_NAME = 'config'
+
+/**
+ * IndexedDB storage service for application configuration
+ */
+export class ConfigStorage {
+ private db: IDBDatabase | null = null
+ private initPromise: Promise | null = null
+
+ /**
+ * Initialize the IndexedDB database
+ */
+ private async init(): Promise {
+ if (this.db) {
+ return
+ }
+
+ if (this.initPromise) {
+ return this.initPromise
+ }
+
+ this.initPromise = this.openDatabase()
+
+ try {
+ await this.initPromise
+ } catch (error) {
+ this.initPromise = null
+ throw error
+ }
+ }
+
+ private openDatabase(): Promise {
+ return new Promise((resolve, reject) => {
+ if (typeof window === 'undefined' || !window.indexedDB) {
+ reject(new Error('IndexedDB is not available. This application requires IndexedDB support.'))
+ return
+ }
+
+ const request = indexedDB.open(DB_NAME, DB_VERSION)
+
+ request.onerror = () => {
+ reject(new Error(`Failed to open IndexedDB: ${request.error}`))
+ }
+
+ request.onsuccess = () => {
+ this.db = request.result
+ resolve()
+ }
+
+ request.onupgradeneeded = (event) => {
+ const db = (event.target as IDBOpenDBRequest).result
+
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ db.createObjectStore(STORE_NAME, { keyPath: 'key' })
+ }
+ }
+ })
+ }
+
+ /**
+ * Get all configuration from IndexedDB or return defaults
+ */
+ async getConfig(): Promise {
+ try {
+ await this.init()
+
+ if (!this.db) {
+ return this.getDefaultConfig()
+ }
+
+ const db = this.db
+ return new Promise((resolve) => {
+ const transaction = db.transaction([STORE_NAME], 'readonly')
+ const store = transaction.objectStore(STORE_NAME)
+ const request = store.get('config')
+
+ request.onsuccess = () => {
+ const result = request.result as { key: string; value: ConfigData } | undefined
+
+ if (!result?.value) {
+ resolve(this.getDefaultConfig())
+ return
+ }
+
+ resolve(result.value)
+ }
+
+ request.onerror = () => {
+ console.warn('Failed to read config from IndexedDB, using defaults')
+ resolve(this.getDefaultConfig())
+ }
+ })
+ } catch (error) {
+ console.error('Error getting config from IndexedDB:', error)
+ return this.getDefaultConfig()
+ }
+ }
+
+ /**
+ * Save configuration to IndexedDB
+ */
+ async saveConfig(config: ConfigData): Promise {
+ try {
+ await this.init()
+
+ if (!this.db) {
+ throw new Error('Database not initialized')
+ }
+
+ const db = this.db
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction([STORE_NAME], 'readwrite')
+ const store = transaction.objectStore(STORE_NAME)
+ const request = store.put({
+ key: 'config',
+ value: {
+ ...config,
+ updatedAt: Date.now(),
+ },
+ })
+
+ request.onsuccess = () => resolve()
+ request.onerror = () => reject(new Error(`Failed to save config: ${request.error}`))
+ })
+ } catch (error) {
+ console.error('Error saving config to IndexedDB:', error)
+ throw error
+ }
+ }
+
+ /**
+ * Get default configuration
+ */
+ private getDefaultConfig(): ConfigData {
+ return {
+ relays: [...DEFAULT_RELAYS],
+ nip95Apis: [...DEFAULT_NIP95_APIS],
+ platformLightningAddress: DEFAULT_PLATFORM_LIGHTNING_ADDRESS,
+ updatedAt: Date.now(),
+ }
+ }
+
+ /**
+ * Get enabled relays sorted by priority
+ */
+ async getEnabledRelays(): Promise {
+ const config = await this.getConfig()
+ return getEnabledRelays(config)
+ }
+
+ /**
+ * Get primary relay URL (first enabled relay)
+ */
+ async getPrimaryRelay(): Promise {
+ const config = await this.getConfig()
+ return getPrimaryRelayFromConfig(config)
+ }
+
+ /**
+ * Get enabled NIP-95 APIs sorted by priority
+ */
+ async getEnabledNip95Apis(): Promise {
+ const config = await this.getConfig()
+ return getEnabledNip95Apis(config)
+ }
+
+ /**
+ * Get primary NIP-95 API URL (first enabled API)
+ */
+ async getPrimaryNip95Api(): Promise {
+ const config = await this.getConfig()
+ return getPrimaryNip95ApiFromConfig(config)
+ }
+
+ /**
+ * Get platform Lightning address
+ */
+ async getPlatformLightningAddress(): Promise {
+ const config = await this.getConfig()
+ return config.platformLightningAddress || DEFAULT_PLATFORM_LIGHTNING_ADDRESS
+ }
+
+ /**
+ * Add a new relay
+ */
+ async addRelay(url: string, enabled: boolean = true, priority?: number): Promise {
+ const config = await this.getConfig()
+ const updatedConfig = addRelayToConfig(config, url, enabled, priority)
+ await this.saveConfig(updatedConfig)
+ }
+
+ /**
+ * Add a new NIP-95 API
+ */
+ async addNip95Api(url: string, enabled: boolean = true, priority?: number): Promise {
+ const config = await this.getConfig()
+ const updatedConfig = addNip95ApiToConfig(config, url, enabled, priority)
+ await this.saveConfig(updatedConfig)
+ }
+
+ /**
+ * Update relay configuration
+ */
+ async updateRelay(id: string, updates: Partial>): Promise {
+ const config = await this.getConfig()
+ const updatedConfig = updateRelayInConfig(config, id, updates)
+ await this.saveConfig(updatedConfig)
+ }
+
+ /**
+ * Update NIP-95 API configuration
+ */
+ async updateNip95Api(id: string, updates: Partial>): Promise {
+ const config = await this.getConfig()
+ const updatedConfig = updateNip95ApiInConfig(config, id, updates)
+ await this.saveConfig(updatedConfig)
+ }
+
+ /**
+ * Remove a relay
+ */
+ async removeRelay(id: string): Promise {
+ const config = await this.getConfig()
+ const updatedConfig = removeRelayFromConfig(config, id)
+ await this.saveConfig(updatedConfig)
+ }
+
+ /**
+ * Remove a NIP-95 API
+ */
+ async removeNip95Api(id: string): Promise {
+ const config = await this.getConfig()
+ const updatedConfig = removeNip95ApiFromConfig(config, id)
+ await this.saveConfig(updatedConfig)
+ }
+
+ /**
+ * Set platform Lightning address
+ */
+ async setPlatformLightningAddress(address: string): Promise {
+ const config = await this.getConfig()
+ config.platformLightningAddress = address
+ await this.saveConfig(config)
+ }
+
+ /**
+ * Check if IndexedDB is available
+ */
+ static isAvailable(): boolean {
+ return typeof window !== 'undefined' && typeof window.indexedDB !== 'undefined'
+ }
+}
+
+export const configStorage = new ConfigStorage()
+
+export { getPrimaryRelaySync, getPrimaryNip95ApiSync, getPlatformLightningAddressSync } from './configStorageSync'
diff --git a/lib/configStorageNip95.ts b/lib/configStorageNip95.ts
new file mode 100644
index 0000000..95fddf8
--- /dev/null
+++ b/lib/configStorageNip95.ts
@@ -0,0 +1,80 @@
+/**
+ * NIP-95 API management methods for ConfigStorage
+ */
+
+import type { Nip95Config, ConfigData } from './configStorageTypes'
+import { DEFAULT_NIP95_APIS } from './configStorageTypes'
+
+export function getEnabledNip95Apis(config: ConfigData): string[] {
+ return config.nip95Apis
+ .filter((api) => api.enabled)
+ .sort((a, b) => a.priority - b.priority)
+ .map((api) => api.url)
+}
+
+export function getPrimaryNip95ApiFromConfig(config: ConfigData): string {
+ const apis = getEnabledNip95Apis(config)
+ if (apis.length === 0) {
+ const defaultApi = DEFAULT_NIP95_APIS[0]
+ if (!defaultApi) {
+ throw new Error('No default NIP-95 API configured')
+ }
+ return defaultApi.url
+ }
+ const primaryApi = apis[0]
+ if (!primaryApi) {
+ const defaultApi = DEFAULT_NIP95_APIS[0]
+ if (!defaultApi) {
+ throw new Error('No default NIP-95 API configured')
+ }
+ return defaultApi.url
+ }
+ return primaryApi
+}
+
+export function createNip95Config(url: string, enabled: boolean, priority: number): Nip95Config {
+ return {
+ id: `nip95_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ url,
+ enabled,
+ priority,
+ createdAt: Date.now(),
+ }
+}
+
+export function addNip95ApiToConfig(config: ConfigData, url: string, enabled: boolean, priority?: number): ConfigData {
+ const maxPriority = Math.max(...config.nip95Apis.map((a) => a.priority), 0)
+ const newApi = createNip95Config(url, enabled, priority ?? maxPriority + 1)
+ return {
+ ...config,
+ nip95Apis: [...config.nip95Apis, newApi],
+ }
+}
+
+export function updateNip95ApiInConfig(
+ config: ConfigData,
+ id: string,
+ updates: Partial>
+): ConfigData {
+ const apiIndex = config.nip95Apis.findIndex((a) => a.id === id)
+ if (apiIndex === -1) {
+ throw new Error(`NIP-95 API with id ${id} not found`)
+ }
+ const existingApi = config.nip95Apis[apiIndex]
+ const updatedApis = [...config.nip95Apis]
+ updatedApis[apiIndex] = {
+ ...existingApi,
+ ...updates,
+ } as Nip95Config
+ return {
+ ...config,
+ nip95Apis: updatedApis,
+ }
+}
+
+export function removeNip95ApiFromConfig(config: ConfigData, id: string): ConfigData {
+ return {
+ ...config,
+ nip95Apis: config.nip95Apis.filter((a) => a.id !== id),
+ }
+}
diff --git a/lib/configStorageRelays.ts b/lib/configStorageRelays.ts
new file mode 100644
index 0000000..4f1a9ee
--- /dev/null
+++ b/lib/configStorageRelays.ts
@@ -0,0 +1,80 @@
+/**
+ * Relay management methods for ConfigStorage
+ */
+
+import type { RelayConfig, ConfigData } from './configStorageTypes'
+import { DEFAULT_RELAYS } from './configStorageTypes'
+
+export function getEnabledRelays(config: ConfigData): string[] {
+ return config.relays
+ .filter((relay) => relay.enabled)
+ .sort((a, b) => a.priority - b.priority)
+ .map((relay) => relay.url)
+}
+
+export function getPrimaryRelayFromConfig(config: ConfigData): string {
+ const relays = getEnabledRelays(config)
+ if (relays.length === 0) {
+ const defaultRelay = DEFAULT_RELAYS[0]
+ if (!defaultRelay) {
+ throw new Error('No default relay configured')
+ }
+ return defaultRelay.url
+ }
+ const primaryRelay = relays[0]
+ if (!primaryRelay) {
+ const defaultRelay = DEFAULT_RELAYS[0]
+ if (!defaultRelay) {
+ throw new Error('No default relay configured')
+ }
+ return defaultRelay.url
+ }
+ return primaryRelay
+}
+
+export function createRelayConfig(url: string, enabled: boolean, priority: number): RelayConfig {
+ return {
+ id: `relay_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
+ url,
+ enabled,
+ priority,
+ createdAt: Date.now(),
+ }
+}
+
+export function addRelayToConfig(config: ConfigData, url: string, enabled: boolean, priority?: number): ConfigData {
+ const maxPriority = Math.max(...config.relays.map((r) => r.priority), 0)
+ const newRelay = createRelayConfig(url, enabled, priority ?? maxPriority + 1)
+ return {
+ ...config,
+ relays: [...config.relays, newRelay],
+ }
+}
+
+export function updateRelayInConfig(
+ config: ConfigData,
+ id: string,
+ updates: Partial>
+): ConfigData {
+ const relayIndex = config.relays.findIndex((r) => r.id === id)
+ if (relayIndex === -1) {
+ throw new Error(`Relay with id ${id} not found`)
+ }
+ const existingRelay = config.relays[relayIndex]
+ const updatedRelays = [...config.relays]
+ updatedRelays[relayIndex] = {
+ ...existingRelay,
+ ...updates,
+ } as RelayConfig
+ return {
+ ...config,
+ relays: updatedRelays,
+ }
+}
+
+export function removeRelayFromConfig(config: ConfigData, id: string): ConfigData {
+ return {
+ ...config,
+ relays: config.relays.filter((r) => r.id !== id),
+ }
+}
diff --git a/lib/configStorageSync.ts b/lib/configStorageSync.ts
new file mode 100644
index 0000000..b086be2
--- /dev/null
+++ b/lib/configStorageSync.ts
@@ -0,0 +1,38 @@
+/**
+ * Synchronous getters for backward compatibility
+ * Returns defaults if IndexedDB is not ready
+ */
+
+import { DEFAULT_RELAYS, DEFAULT_NIP95_APIS, DEFAULT_PLATFORM_LIGHTNING_ADDRESS } from './configStorageTypes'
+
+/**
+ * Synchronous getter for primary relay (for backward compatibility)
+ * Returns default if IndexedDB is not ready
+ */
+export function getPrimaryRelaySync(): string {
+ const defaultRelay = DEFAULT_RELAYS[0]
+ if (!defaultRelay) {
+ throw new Error('No default relay configured')
+ }
+ return defaultRelay.url
+}
+
+/**
+ * Synchronous getter for primary NIP-95 API (for backward compatibility)
+ * Returns default if IndexedDB is not ready
+ */
+export function getPrimaryNip95ApiSync(): string {
+ const defaultApi = DEFAULT_NIP95_APIS[0]
+ if (!defaultApi) {
+ throw new Error('No default NIP-95 API configured')
+ }
+ return defaultApi.url
+}
+
+/**
+ * Synchronous getter for platform Lightning address (for backward compatibility)
+ * Returns default if IndexedDB is not ready
+ */
+export function getPlatformLightningAddressSync(): string {
+ return DEFAULT_PLATFORM_LIGHTNING_ADDRESS
+}
diff --git a/lib/configStorageTypes.ts b/lib/configStorageTypes.ts
new file mode 100644
index 0000000..5850922
--- /dev/null
+++ b/lib/configStorageTypes.ts
@@ -0,0 +1,51 @@
+/**
+ * Types and default configurations for ConfigStorage
+ */
+
+export interface RelayConfig {
+ id: string
+ url: string
+ enabled: boolean
+ priority: number
+ createdAt: number
+}
+
+export interface Nip95Config {
+ id: string
+ url: string
+ enabled: boolean
+ priority: number
+ createdAt: number
+}
+
+export interface ConfigData {
+ relays: RelayConfig[]
+ nip95Apis: Nip95Config[]
+ platformLightningAddress: string
+ updatedAt: number
+}
+
+/**
+ * Default configuration values (hardcoded in the code)
+ */
+export const DEFAULT_RELAYS: RelayConfig[] = [
+ {
+ id: 'default',
+ url: 'wss://relay.damus.io',
+ enabled: true,
+ priority: 1,
+ createdAt: Date.now(),
+ },
+]
+
+export const DEFAULT_NIP95_APIS: Nip95Config[] = [
+ {
+ id: 'default',
+ url: 'https://nostr.build/api/v2/upload',
+ enabled: true,
+ priority: 1,
+ createdAt: Date.now(),
+ },
+]
+
+export const DEFAULT_PLATFORM_LIGHTNING_ADDRESS = ''
diff --git a/lib/contentDeliveryVerification.ts b/lib/contentDeliveryVerification.ts
index 816ea0d..e4cd221 100644
--- a/lib/contentDeliveryVerification.ts
+++ b/lib/contentDeliveryVerification.ts
@@ -1,7 +1,6 @@
import { nostrService } from './nostr'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
-
-const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
+import { getPrimaryRelaySync } from './config'
export interface ContentDeliveryStatus {
messageEventId: string | null
@@ -15,7 +14,94 @@ export interface ContentDeliveryStatus {
* Verify that private content was successfully delivered to recipient
* Checks multiple aspects to ensure delivery certainty
*/
-export async function verifyContentDelivery(
+function createContentDeliveryFilters(authorPubkey: string, recipientPubkey: string, articleId: string, messageEventId: string) {
+ const filters: Array<{
+ kinds: number[]
+ ids?: string[]
+ authors: string[]
+ '#p': string[]
+ '#e': string[]
+ limit: number
+ }> = [
+ {
+ kinds: [4],
+ authors: [authorPubkey],
+ '#p': [recipientPubkey],
+ '#e': [articleId],
+ limit: 1,
+ },
+ ]
+
+ if (messageEventId && filters[0]) {
+ filters[0].ids = [messageEventId]
+ }
+ return filters
+}
+
+function setupContentDeliveryHandlers(
+ sub: SimplePoolWithSub['sub'] extends (...args: any[]) => infer R ? R : never,
+ status: ContentDeliveryStatus,
+ finalize: (result: ContentDeliveryStatus) => void,
+ isResolved: () => boolean
+): void {
+ sub.on('event', (event) => {
+ status.published = true
+ status.verifiedOnRelay = true
+ status.messageEventId = event.id
+ status.retrievable = true
+ finalize(status)
+ })
+
+ sub.on('eose', () => {
+ if (!status.published) {
+ status.error = 'Message not found on relay'
+ }
+ finalize(status)
+ })
+
+ setTimeout(() => {
+ if (!isResolved()) {
+ if (!status.published) {
+ status.error = 'Timeout waiting for message verification'
+ }
+ finalize(status)
+ }
+ }, 5000)
+}
+
+function createContentDeliverySubscription(
+ pool: SimplePoolWithSub,
+ authorPubkey: string,
+ recipientPubkey: string,
+ articleId: string,
+ messageEventId: string
+) {
+ const filters = createContentDeliveryFilters(authorPubkey, recipientPubkey, articleId, messageEventId)
+ const relayUrl = getPrimaryRelaySync()
+ return pool.sub([relayUrl], filters)
+}
+
+function createContentDeliveryPromise(
+ sub: SimplePoolWithSub['sub'] extends (...args: any[]) => infer R ? R : never,
+ status: ContentDeliveryStatus
+): Promise {
+ return new Promise((resolve) => {
+ let resolved = false
+
+ const finalize = (result: ContentDeliveryStatus) => {
+ if (resolved) {
+ return
+ }
+ resolved = true
+ sub.unsub()
+ resolve(result)
+ }
+
+ setupContentDeliveryHandlers(sub, status, finalize, () => resolved)
+ })
+}
+
+export function verifyContentDelivery(
articleId: string,
authorPubkey: string,
recipientPubkey: string,
@@ -32,72 +118,15 @@ export async function verifyContentDelivery(
const pool = nostrService.getPool()
if (!pool) {
status.error = 'Pool not initialized'
- return status
+ return Promise.resolve(status)
}
const poolWithSub = pool as SimplePoolWithSub
-
- const filters: Array<{
- kinds: number[]
- ids?: string[]
- authors: string[]
- '#p': string[]
- '#e': string[]
- limit: number
- }> = [
- {
- kinds: [4],
- authors: [authorPubkey],
- '#p': [recipientPubkey],
- '#e': [articleId],
- limit: 1,
- },
- ]
-
- if (messageEventId && filters[0]) {
- filters[0].ids = [messageEventId]
- }
-
- return new Promise((resolve) => {
- let resolved = false
- const sub = poolWithSub.sub([RELAY_URL], filters)
-
- const finalize = (result: ContentDeliveryStatus) => {
- if (resolved) {
- return
- }
- resolved = true
- sub.unsub()
- resolve(result)
- }
-
- sub.on('event', (event) => {
- status.published = true
- status.verifiedOnRelay = true
- status.messageEventId = event.id
- status.retrievable = true
- finalize(status)
- })
-
- sub.on('eose', () => {
- if (!status.published) {
- status.error = 'Message not found on relay'
- }
- finalize(status)
- })
-
- setTimeout(() => {
- if (!resolved) {
- if (!status.published) {
- status.error = 'Timeout waiting for message verification'
- }
- finalize(status)
- }
- }, 5000)
- })
+ const sub = createContentDeliverySubscription(poolWithSub, authorPubkey, recipientPubkey, articleId, messageEventId)
+ return createContentDeliveryPromise(sub, status)
} catch (error) {
status.error = error instanceof Error ? error.message : 'Unknown error'
- return status
+ return Promise.resolve(status)
}
}
diff --git a/lib/fundingCalculation.ts b/lib/fundingCalculation.ts
index f467026..0650711 100644
--- a/lib/fundingCalculation.ts
+++ b/lib/fundingCalculation.ts
@@ -22,50 +22,53 @@ export interface FundingStats {
* This is an approximation based on commission rates
* Actual calculation would require querying all transactions
*/
-export async function calculatePlatformFunds(_authorPubkeys: string[]): Promise {
- let totalSats = 0
-
- // Calculate article commissions (from zap receipts with kind_type: purchase)
- // Each article payment is 800 sats, platform gets 100 sats
- // This is an approximation - in reality we'd query all zap receipts
+async function calculateArticleCommissions(): Promise {
try {
const articleCommissions = await aggregateZapSats({
authorPubkey: '', // Empty to get all
kindType: 'purchase',
})
- // Estimate: assume 100 sats commission per purchase (800 total, 700 author, 100 platform)
- // This is simplified - actual calculation would need to track each payment
- totalSats += Math.floor(articleCommissions * (PLATFORM_COMMISSIONS.article.platform / PLATFORM_COMMISSIONS.article.total))
+ return Math.floor(articleCommissions * (PLATFORM_COMMISSIONS.article.platform / PLATFORM_COMMISSIONS.article.total))
} catch (e) {
console.error('Error calculating article commissions:', e)
+ return 0
}
+}
- // Calculate review commissions (from zap receipts with kind_type: review_tip)
- // Each review tip is 70 sats, platform gets 21 sats
+async function calculateReviewCommissions(): Promise {
try {
const reviewCommissions = await aggregateZapSats({
authorPubkey: '', // Empty to get all
kindType: 'review_tip',
})
- // Estimate: assume 21 sats commission per review tip (70 total, 49 reviewer, 21 platform)
- totalSats += Math.floor(reviewCommissions * (PLATFORM_COMMISSIONS.review.platform / PLATFORM_COMMISSIONS.review.total))
+ return Math.floor(reviewCommissions * (PLATFORM_COMMISSIONS.review.platform / PLATFORM_COMMISSIONS.review.total))
} catch (e) {
console.error('Error calculating review commissions:', e)
+ return 0
}
+}
- // Calculate sponsoring commissions (from zap receipts with kind_type: sponsoring)
- // Each sponsoring is 0.046 BTC, platform gets 0.004 BTC (400,000 sats)
+async function calculateSponsoringCommissions(): Promise {
try {
const sponsoringCommissions = await aggregateZapSats({
authorPubkey: '', // Empty to get all
kindType: 'sponsoring',
})
- // Estimate: assume 400,000 sats commission per sponsoring (4,600,000 total, 4,200,000 author, 400,000 platform)
- totalSats += Math.floor(sponsoringCommissions * (PLATFORM_COMMISSIONS.sponsoring.platformSats / PLATFORM_COMMISSIONS.sponsoring.totalSats))
+ return Math.floor(sponsoringCommissions * (PLATFORM_COMMISSIONS.sponsoring.platformSats / PLATFORM_COMMISSIONS.sponsoring.totalSats))
} catch (e) {
console.error('Error calculating sponsoring commissions:', e)
+ return 0
}
+}
+export async function calculatePlatformFunds(_authorPubkeys: string[]): Promise {
+ const [articleCommissions, reviewCommissions, sponsoringCommissions] = await Promise.all([
+ calculateArticleCommissions(),
+ calculateReviewCommissions(),
+ calculateSponsoringCommissions(),
+ ])
+
+ const totalSats = articleCommissions + reviewCommissions + sponsoringCommissions
const totalBTC = totalSats / 100_000_000
const progressPercent = Math.min(100, (totalBTC / FUNDING_TARGET_BTC) * 100)
diff --git a/lib/i18n.ts b/lib/i18n.ts
index f42b72f..94a37c7 100644
--- a/lib/i18n.ts
+++ b/lib/i18n.ts
@@ -30,7 +30,7 @@ export function getLocale(): Locale {
* Load translations from a flat text file
* Format: key=value (one per line, empty lines and lines starting with # are ignored)
*/
-export async function loadTranslations(locale: Locale, translationsText: string): Promise {
+export function loadTranslations(locale: Locale, translationsText: string): void {
const translationsMap: Translations = {}
const lines = translationsText.split('\n')
diff --git a/lib/keyManagement.ts b/lib/keyManagement.ts
new file mode 100644
index 0000000..1bf0235
--- /dev/null
+++ b/lib/keyManagement.ts
@@ -0,0 +1,165 @@
+import { nip19, getPublicKey, generatePrivateKey } from 'nostr-tools'
+import { generateRecoveryPhrase } from './keyManagementRecovery'
+import { deriveKeyFromPhrase, encryptNsec, decryptNsec } from './keyManagementEncryption'
+import {
+ storeAccountFlag,
+ hasAccountFlag,
+ removeAccountFlag,
+ getEncryptedKey,
+ setEncryptedKey,
+ getPublicKeys as getStoredPublicKeys,
+ setPublicKeys,
+ deleteStoredKeys,
+} from './keyManagementStorage'
+
+/**
+ * Key management service
+ */
+export class KeyManagementService {
+ /**
+ * Generate a new Nostr key pair
+ * Returns the private key (hex) and public key (hex)
+ */
+ generateKeyPair(): { privateKey: string; publicKey: string; npub: string } {
+ const privateKeyHex = generatePrivateKey()
+ const publicKeyHex = getPublicKey(privateKeyHex)
+ const npub = nip19.npubEncode(publicKeyHex)
+
+ return {
+ privateKey: privateKeyHex,
+ publicKey: publicKeyHex,
+ npub,
+ }
+ }
+
+ /**
+ * Import a private key (accepts hex or nsec format)
+ * Returns the private key (hex), public key (hex), and npub
+ */
+ importPrivateKey(privateKey: string): { privateKey: string; publicKey: string; npub: string } {
+ let privateKeyHex: string
+
+ // Try to decode as nsec
+ try {
+ const decoded = nip19.decode(privateKey)
+ if (decoded.type === 'nsec' && typeof decoded.data === 'string') {
+ privateKeyHex = decoded.data
+ } else {
+ throw new Error('Invalid nsec format')
+ }
+ } catch {
+ // Assume it's already a hex string
+ privateKeyHex = privateKey
+ }
+
+ const publicKeyHex = getPublicKey(privateKeyHex)
+ const npub = nip19.npubEncode(publicKeyHex)
+
+ return {
+ privateKey: privateKeyHex,
+ publicKey: publicKeyHex,
+ npub,
+ }
+ }
+
+ /**
+ * Create a new account: generate/import key, encrypt it, and store it
+ * Returns the recovery phrase and npub
+ */
+ async createAccount(privateKey?: string): Promise<{
+ recoveryPhrase: string[]
+ npub: string
+ publicKey: string
+ }> {
+ // Generate or import key pair
+ const keyPair = privateKey ? this.importPrivateKey(privateKey) : this.generateKeyPair()
+
+ // Generate recovery phrase
+ const recoveryPhrase = generateRecoveryPhrase()
+
+ // Derive encryption key from recovery phrase
+ const derivedKey = await deriveKeyFromPhrase(recoveryPhrase)
+
+ // Encrypt the private key
+ const encryptedNsec = await encryptNsec(derivedKey, keyPair.privateKey)
+
+ // Store encrypted nsec in IndexedDB
+ await setEncryptedKey(encryptedNsec)
+
+ // Store account flag in browser storage
+ storeAccountFlag()
+
+ // Store public key separately for quick access
+ await setPublicKeys(keyPair.publicKey, keyPair.npub)
+
+ return {
+ recoveryPhrase,
+ npub: keyPair.npub,
+ publicKey: keyPair.publicKey,
+ }
+ }
+
+ /**
+ * Check if an account exists (encrypted key is stored)
+ */
+ async accountExists(): Promise {
+ try {
+ // Check both the flag and the actual stored key
+ if (!hasAccountFlag()) {
+ return false
+ }
+ const encrypted = await getEncryptedKey()
+ return encrypted !== null
+ } catch {
+ return false
+ }
+ }
+
+ /**
+ * Get the public key and npub if account exists
+ */
+ async getPublicKeys(): Promise<{ publicKey: string; npub: string } | null> {
+ return await getStoredPublicKeys()
+ }
+
+ /**
+ * Decrypt and retrieve the private key using recovery phrase
+ */
+ async unlockAccount(recoveryPhrase: string[]): Promise<{
+ privateKey: string
+ publicKey: string
+ npub: string
+ }> {
+ // Get encrypted nsec from IndexedDB
+ const encryptedNsec = await getEncryptedKey()
+ if (!encryptedNsec) {
+ throw new Error('No encrypted key found. Please create an account first.')
+ }
+
+ // Derive key from recovery phrase
+ const derivedKey = await deriveKeyFromPhrase(recoveryPhrase)
+
+ // Decrypt the private key
+ const privateKeyHex = await decryptNsec(derivedKey, encryptedNsec)
+
+ // Verify by computing public key
+ const publicKeyHex = getPublicKey(privateKeyHex)
+ const npub = nip19.npubEncode(publicKeyHex)
+
+ return {
+ privateKey: privateKeyHex,
+ publicKey: publicKeyHex,
+ npub,
+ }
+ }
+
+ /**
+ * Delete the account (remove all stored keys)
+ */
+ async deleteAccount(): Promise {
+ await deleteStoredKeys()
+ removeAccountFlag()
+ }
+}
+
+export const keyManagementService = new KeyManagementService()
diff --git a/lib/keyManagementEncryption.ts b/lib/keyManagementEncryption.ts
new file mode 100644
index 0000000..3cb7ffb
--- /dev/null
+++ b/lib/keyManagementEncryption.ts
@@ -0,0 +1,115 @@
+export interface EncryptedPayload {
+ iv: string
+ ciphertext: string
+}
+
+const SALT_LENGTH = 32
+const PBKDF2_ITERATIONS = 100000
+const PBKDF2_HASH = 'SHA-256'
+const KEY_LENGTH = 32
+
+/**
+ * Derive an encryption key from a recovery phrase using PBKDF2
+ */
+export async function deriveKeyFromPhrase(phrase: string[]): Promise {
+ const phraseString = phrase.join(' ')
+ const encoder = new TextEncoder()
+ const password = encoder.encode(phraseString)
+
+ // Generate a deterministic salt from the phrase itself
+ // This ensures the same phrase always generates the same key
+ const saltBuffer = await crypto.subtle.digest('SHA-256', password)
+ const saltArray = new Uint8Array(saltBuffer)
+ const salt = saltArray.slice(0, SALT_LENGTH)
+
+ // Import password as key material
+ const keyMaterial = await crypto.subtle.importKey(
+ 'raw',
+ password,
+ 'PBKDF2',
+ false,
+ ['deriveBits', 'deriveKey']
+ )
+
+ // Derive key using PBKDF2
+ const derivedKey = await crypto.subtle.deriveKey(
+ {
+ name: 'PBKDF2',
+ salt,
+ iterations: PBKDF2_ITERATIONS,
+ hash: PBKDF2_HASH,
+ },
+ keyMaterial,
+ { name: 'AES-GCM', length: KEY_LENGTH * 8 },
+ false,
+ ['encrypt', 'decrypt']
+ )
+
+ return derivedKey
+}
+
+/**
+ * Encrypt nsec with derived key
+ */
+export async function encryptNsec(derivedKey: CryptoKey, nsecHex: string): Promise {
+ const encoder = new TextEncoder()
+ const data = encoder.encode(nsecHex)
+ const iv = crypto.getRandomValues(new Uint8Array(12))
+
+ const encrypted = await crypto.subtle.encrypt(
+ { name: 'AES-GCM', iv },
+ derivedKey,
+ data
+ )
+
+ const encryptedArray = new Uint8Array(encrypted)
+
+ // Convert to base64 for storage
+ function toBase64(bytes: Uint8Array): string {
+ let binary = ''
+ bytes.forEach((b) => {
+ binary += String.fromCharCode(b)
+ })
+ return btoa(binary)
+ }
+
+ return {
+ iv: toBase64(iv),
+ ciphertext: toBase64(encryptedArray),
+ }
+}
+
+/**
+ * Decrypt nsec with derived key
+ */
+export async function decryptNsec(derivedKey: CryptoKey, payload: EncryptedPayload): Promise {
+ function fromBase64(value: string): Uint8Array {
+ const binary = atob(value)
+ const bytes = new Uint8Array(binary.length)
+ for (let i = 0; i < binary.length; i += 1) {
+ bytes[i] = binary.charCodeAt(i)
+ }
+ return bytes
+ }
+
+ const iv = fromBase64(payload.iv)
+ const ciphertext = fromBase64(payload.ciphertext)
+
+ // Ensure iv and ciphertext are proper ArrayBuffer views
+ const ivBuffer = iv.buffer instanceof ArrayBuffer ? iv.buffer : new ArrayBuffer(iv.byteLength)
+ const ivView = new Uint8Array(ivBuffer, 0, iv.byteLength)
+ ivView.set(iv)
+
+ const cipherBuffer = ciphertext.buffer instanceof ArrayBuffer ? ciphertext.buffer : new ArrayBuffer(ciphertext.byteLength)
+ const cipherView = new Uint8Array(cipherBuffer, 0, ciphertext.byteLength)
+ cipherView.set(ciphertext)
+
+ const decrypted = await crypto.subtle.decrypt(
+ { name: 'AES-GCM', iv: ivView },
+ derivedKey,
+ cipherView
+ )
+
+ const decoder = new TextDecoder()
+ return decoder.decode(decrypted)
+}
diff --git a/lib/keyManagementRecovery.ts b/lib/keyManagementRecovery.ts
new file mode 100644
index 0000000..a6f4e21
--- /dev/null
+++ b/lib/keyManagementRecovery.ts
@@ -0,0 +1,36 @@
+/**
+ * Word list for generating 4-word recovery phrases
+ * Using common French words for better user experience
+ */
+const WORD_LIST = [
+ 'abeille', 'arbre', 'avion', 'bateau', 'café', 'chaton', 'ciel', 'cœur', 'diamant', 'étoile',
+ 'fleur', 'forêt', 'guitare', 'jardin', 'livre', 'lune', 'miel', 'montagne', 'océan', 'papillon',
+ 'piano', 'plage', 'plume', 'rivière', 'soleil', 'tigre', 'voiture', 'vague', 'vent', 'éclair',
+ 'arc', 'banane', 'canard', 'carotte', 'cerise', 'cochon', 'crocodile', 'éléphant', 'grenouille', 'hibou',
+ 'kiwi', 'lapin', 'légume', 'loup', 'mouton', 'orange', 'panda', 'pomme', 'renard', 'serpent',
+ 'tigre', 'tomate', 'tortue', 'vache', 'zèbre', 'balle', 'ballon', 'bateau', 'camion', 'crayon',
+ 'livre', 'maison', 'table', 'chaise', 'fenêtre', 'porte', 'lampe', 'clé', 'roue', 'arbre'
+]
+
+/**
+ * Generate a random 4-word recovery phrase
+ */
+export function generateRecoveryPhrase(): string[] {
+ const words: string[] = []
+ const random = crypto.getRandomValues(new Uint32Array(4))
+
+ for (let i = 0; i < 4; i += 1) {
+ const randomValue = random[i]
+ if (randomValue === undefined) {
+ throw new Error('Failed to generate random value')
+ }
+ const index = randomValue % WORD_LIST.length
+ const word = WORD_LIST[index]
+ if (word === undefined) {
+ throw new Error('Invalid word index')
+ }
+ words.push(word)
+ }
+
+ return words
+}
diff --git a/lib/keyManagementStorage.ts b/lib/keyManagementStorage.ts
new file mode 100644
index 0000000..09e46c7
--- /dev/null
+++ b/lib/keyManagementStorage.ts
@@ -0,0 +1,62 @@
+import type { EncryptedPayload } from './keyManagementEncryption'
+import { storageService } from './storage/indexedDB'
+
+export const KEY_STORAGE_KEY = 'nostr_encrypted_key'
+
+/**
+ * Store account identifier in browser storage
+ * This is just a flag to indicate that an account exists
+ */
+export function storeAccountFlag(): void {
+ if (typeof window === 'undefined' || !window.localStorage) {
+ throw new Error('localStorage not available')
+ }
+
+ localStorage.setItem('nostr_account_exists', 'true')
+}
+
+/**
+ * Check if account flag exists
+ */
+export function hasAccountFlag(): boolean {
+ if (typeof window === 'undefined' || !window.localStorage) {
+ return false
+ }
+
+ return localStorage.getItem('nostr_account_exists') === 'true'
+}
+
+/**
+ * Remove account flag from browser storage
+ */
+export function removeAccountFlag(): void {
+ if (typeof window !== 'undefined' && window.localStorage) {
+ localStorage.removeItem('nostr_account_exists')
+ }
+}
+
+export async function getEncryptedKey(): Promise {
+ return await storageService.get(KEY_STORAGE_KEY, 'nostr_key_storage')
+}
+
+export async function setEncryptedKey(encryptedNsec: EncryptedPayload): Promise {
+ await storageService.set(KEY_STORAGE_KEY, encryptedNsec, 'nostr_key_storage')
+}
+
+export async function getPublicKeys(): Promise<{ publicKey: string; npub: string } | null> {
+ try {
+ const stored = await storageService.get<{ publicKey: string; npub: string }>('nostr_public_key', 'nostr_key_storage')
+ return stored
+ } catch {
+ return null
+ }
+}
+
+export async function setPublicKeys(publicKey: string, npub: string): Promise {
+ await storageService.set('nostr_public_key', { publicKey, npub }, 'nostr_key_storage')
+}
+
+export async function deleteStoredKeys(): Promise {
+ await storageService.delete(KEY_STORAGE_KEY)
+ await storageService.delete('nostr_public_key')
+}
diff --git a/lib/mempoolSpace.ts b/lib/mempoolSpace.ts
index 7605e3c..090c2eb 100644
--- a/lib/mempoolSpace.ts
+++ b/lib/mempoolSpace.ts
@@ -1,36 +1,14 @@
-import { calculateSponsoringSplit } from './platformCommissions'
-import { PLATFORM_BITCOIN_ADDRESS } from './platformConfig'
+import type { MempoolTransaction, TransactionVerificationResult } from './mempoolSpaceTypes'
+import { getTransaction } from './mempoolSpaceApi'
+import { verifySponsoringTransaction } from './mempoolSpaceVerification'
+import { waitForConfirmation } from './mempoolSpaceConfirmation'
-const MEMPOOL_API_BASE = 'https://mempool.space/api'
-
-export interface MempoolTransaction {
- txid: string
- vout: Array<{
- value: number // in sats
- scriptpubkey_address: string
- }>
- status: {
- confirmed: boolean
- block_height?: number
- block_hash?: string
- }
-}
-
-export interface TransactionVerificationResult {
- valid: boolean
- confirmed: boolean
- confirmations: number
- authorOutput?: {
- address: string
- amount: number
- }
- platformOutput?: {
- address: string
- amount: number
- }
- error?: string | undefined
-}
+export type { MempoolTransaction, TransactionVerificationResult } from './mempoolSpaceTypes'
+/**
+ * Mempool.space API service
+ * Used to verify Bitcoin mainnet transactions for sponsoring payments
+ */
/**
* Mempool.space API service
* Used to verify Bitcoin mainnet transactions for sponsoring payments
@@ -40,27 +18,7 @@ export class MempoolSpaceService {
* Fetch transaction from mempool.space
*/
async getTransaction(txid: string): Promise {
- try {
- const response = await fetch(`${MEMPOOL_API_BASE}/tx/${txid}`)
-
- if (!response.ok) {
- if (response.status === 404) {
- console.warn('Transaction not found on mempool.space', { txid })
- return null
- }
- throw new Error(`Failed to fetch transaction: ${response.status} ${response.statusText}`)
- }
-
- const transaction = await response.json() as MempoolTransaction
- return transaction
- } catch (error) {
- console.error('Error fetching transaction from mempool.space', {
- txid,
- error: error instanceof Error ? error.message : 'Unknown error',
- timestamp: new Date().toISOString(),
- })
- return null
- }
+ return await getTransaction(txid)
}
/**
@@ -71,115 +29,7 @@ export class MempoolSpaceService {
txid: string,
authorMainnetAddress: string
): Promise {
- try {
- const transaction = await this.getTransaction(txid)
-
- if (!transaction) {
- return {
- valid: false,
- confirmed: false,
- confirmations: 0,
- error: 'Transaction not found',
- }
- }
-
- const split = calculateSponsoringSplit()
- const expectedAuthorAmount = split.authorSats
- const expectedPlatformAmount = split.platformSats
-
- // Find outputs matching expected addresses and amounts
- const authorOutput = transaction.vout.find(
- (output) =>
- output.scriptpubkey_address === authorMainnetAddress &&
- output.value === expectedAuthorAmount
- )
-
- const platformOutput = transaction.vout.find(
- (output) =>
- output.scriptpubkey_address === PLATFORM_BITCOIN_ADDRESS &&
- output.value === expectedPlatformAmount
- )
-
- const valid = Boolean(authorOutput && platformOutput)
- const confirmed = transaction.status.confirmed
- const confirmations = confirmed && transaction.status.block_height
- ? await this.getConfirmations(transaction.status.block_height)
- : 0
-
- if (!valid) {
- console.error('Transaction verification failed', {
- txid,
- authorAddress: authorMainnetAddress,
- platformAddress: PLATFORM_BITCOIN_ADDRESS,
- expectedAuthorAmount,
- expectedPlatformAmount,
- actualOutputs: transaction.vout.map((o) => ({
- address: o.scriptpubkey_address,
- amount: o.value,
- })),
- timestamp: new Date().toISOString(),
- })
- }
-
- const result: TransactionVerificationResult = {
- valid,
- confirmed,
- confirmations,
- }
-
- if (!valid) {
- result.error = 'Transaction outputs do not match expected split'
- }
-
- if (authorOutput) {
- result.authorOutput = {
- address: authorOutput.scriptpubkey_address,
- amount: authorOutput.value,
- }
- }
-
- if (platformOutput) {
- result.platformOutput = {
- address: platformOutput.scriptpubkey_address,
- amount: platformOutput.value,
- }
- }
-
- return result
- } catch (error) {
- const errorMessage = error instanceof Error ? error.message : 'Unknown error'
- console.error('Error verifying sponsoring transaction', {
- txid,
- authorAddress: authorMainnetAddress,
- error: errorMessage,
- timestamp: new Date().toISOString(),
- })
- return {
- valid: false,
- confirmed: false,
- confirmations: 0,
- error: errorMessage,
- }
- }
- }
-
- /**
- * Get current block height and calculate confirmations
- */
- private async getConfirmations(blockHeight: number): Promise {
- try {
- const response = await fetch(`${MEMPOOL_API_BASE}/blocks/tip/height`)
- if (!response.ok) {
- return 0
- }
- const currentHeight = await response.json() as number
- return Math.max(0, currentHeight - blockHeight + 1)
- } catch (error) {
- console.error('Error getting current block height', {
- error: error instanceof Error ? error.message : 'Unknown error',
- })
- return 0
- }
+ return await verifySponsoringTransaction(txid, authorMainnetAddress)
}
/**
@@ -191,42 +41,7 @@ export class MempoolSpaceService {
timeout: number = 600000, // 10 minutes
interval: number = 10000 // 10 seconds
): Promise {
- const startTime = Date.now()
-
- return new Promise((resolve) => {
- const checkConfirmation = async () => {
- if (Date.now() - startTime > timeout) {
- resolve(null)
- return
- }
-
- // Get author address from transaction (first output that's not platform)
- const transaction = await this.getTransaction(txid)
- if (!transaction) {
- setTimeout(checkConfirmation, interval)
- return
- }
-
- const authorOutput = transaction.vout.find(
- (output) => output.scriptpubkey_address !== PLATFORM_BITCOIN_ADDRESS
- )
-
- if (!authorOutput) {
- setTimeout(checkConfirmation, interval)
- return
- }
-
- const result = await this.verifySponsoringTransaction(txid, authorOutput.scriptpubkey_address)
-
- if (result.confirmed && result.valid) {
- resolve(result)
- } else {
- setTimeout(checkConfirmation, interval)
- }
- }
-
- checkConfirmation()
- })
+ return await waitForConfirmation(txid, timeout, interval)
}
}
diff --git a/lib/mempoolSpaceApi.ts b/lib/mempoolSpaceApi.ts
new file mode 100644
index 0000000..e17e8c4
--- /dev/null
+++ b/lib/mempoolSpaceApi.ts
@@ -0,0 +1,43 @@
+import type { MempoolTransaction } from './mempoolSpaceTypes'
+
+const MEMPOOL_API_BASE = 'https://mempool.space/api'
+
+export async function getTransaction(txid: string): Promise {
+ try {
+ const response = await fetch(`${MEMPOOL_API_BASE}/tx/${txid}`)
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ console.warn('Transaction not found on mempool.space', { txid })
+ return null
+ }
+ throw new Error(`Failed to fetch transaction: ${response.status} ${response.statusText}`)
+ }
+
+ const transaction = await response.json() as MempoolTransaction
+ return transaction
+ } catch (error) {
+ console.error('Error fetching transaction from mempool.space', {
+ txid,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ timestamp: new Date().toISOString(),
+ })
+ return null
+ }
+}
+
+export async function getConfirmations(blockHeight: number): Promise {
+ try {
+ const response = await fetch(`${MEMPOOL_API_BASE}/blocks/tip/height`)
+ if (!response.ok) {
+ return 0
+ }
+ const currentHeight = await response.json() as number
+ return Math.max(0, currentHeight - blockHeight + 1)
+ } catch (error) {
+ console.error('Error getting current block height', {
+ error: error instanceof Error ? error.message : 'Unknown error',
+ })
+ return 0
+ }
+}
diff --git a/lib/mempoolSpaceConfirmation.ts b/lib/mempoolSpaceConfirmation.ts
new file mode 100644
index 0000000..8ffc534
--- /dev/null
+++ b/lib/mempoolSpaceConfirmation.ts
@@ -0,0 +1,61 @@
+import { PLATFORM_BITCOIN_ADDRESS } from './platformConfig'
+import type { TransactionVerificationResult } from './mempoolSpaceTypes'
+import { getTransaction } from './mempoolSpaceApi'
+import { verifySponsoringTransaction } from './mempoolSpaceVerification'
+
+export function scheduleNextCheck(checkConfirmation: () => void, interval: number) {
+ setTimeout(() => {
+ void checkConfirmation()
+ }, interval)
+}
+
+export async function checkTransactionStatus(
+ txid: string,
+ startTime: number,
+ timeout: number,
+ interval: number,
+ resolve: (value: TransactionVerificationResult | null) => void,
+ checkConfirmation: () => void
+): Promise {
+ if (Date.now() - startTime > timeout) {
+ resolve(null)
+ return
+ }
+
+ const transaction = await getTransaction(txid)
+ if (!transaction) {
+ scheduleNextCheck(checkConfirmation, interval)
+ return
+ }
+
+ const authorOutput = transaction.vout.find(
+ (output) => output.scriptpubkey_address !== PLATFORM_BITCOIN_ADDRESS
+ )
+
+ if (!authorOutput) {
+ scheduleNextCheck(checkConfirmation, interval)
+ return
+ }
+
+ const result = await verifySponsoringTransaction(txid, authorOutput.scriptpubkey_address)
+ if (result.confirmed && result.valid) {
+ resolve(result)
+ } else {
+ scheduleNextCheck(checkConfirmation, interval)
+ }
+}
+
+export async function waitForConfirmation(
+ txid: string,
+ timeout: number = 600000, // 10 minutes
+ interval: number = 10000 // 10 seconds
+): Promise {
+ const startTime = Date.now()
+
+ return new Promise((resolve) => {
+ const checkConfirmation = async () => {
+ await checkTransactionStatus(txid, startTime, timeout, interval, resolve, checkConfirmation)
+ }
+ void checkConfirmation()
+ })
+}
diff --git a/lib/mempoolSpaceTypes.ts b/lib/mempoolSpaceTypes.ts
new file mode 100644
index 0000000..66263aa
--- /dev/null
+++ b/lib/mempoolSpaceTypes.ts
@@ -0,0 +1,27 @@
+export interface MempoolTransaction {
+ txid: string
+ vout: Array<{
+ value: number // in sats
+ scriptpubkey_address: string
+ }>
+ status: {
+ confirmed: boolean
+ block_height?: number
+ block_hash?: string
+ }
+}
+
+export interface TransactionVerificationResult {
+ valid: boolean
+ confirmed: boolean
+ confirmations: number
+ authorOutput?: {
+ address: string
+ amount: number
+ }
+ platformOutput?: {
+ address: string
+ amount: number
+ }
+ error?: string | undefined
+}
diff --git a/lib/mempoolSpaceVerification.ts b/lib/mempoolSpaceVerification.ts
new file mode 100644
index 0000000..27a74bc
--- /dev/null
+++ b/lib/mempoolSpaceVerification.ts
@@ -0,0 +1,169 @@
+import { calculateSponsoringSplit } from './platformCommissions'
+import { PLATFORM_BITCOIN_ADDRESS } from './platformConfig'
+import type { MempoolTransaction, TransactionVerificationResult } from './mempoolSpaceTypes'
+import { getTransaction, getConfirmations } from './mempoolSpaceApi'
+
+export function findTransactionOutputs(
+ transaction: MempoolTransaction,
+ authorMainnetAddress: string,
+ expectedAuthorAmount: number,
+ expectedPlatformAmount: number
+): { authorOutput?: MempoolTransaction['vout'][0]; platformOutput?: MempoolTransaction['vout'][0] } {
+ const authorOutput = transaction.vout.find(
+ (output) =>
+ output.scriptpubkey_address === authorMainnetAddress &&
+ output.value === expectedAuthorAmount
+ )
+
+ const platformOutput = transaction.vout.find(
+ (output) =>
+ output.scriptpubkey_address === PLATFORM_BITCOIN_ADDRESS &&
+ output.value === expectedPlatformAmount
+ )
+
+ const result: { authorOutput?: MempoolTransaction['vout'][0]; platformOutput?: MempoolTransaction['vout'][0] } = {}
+ if (authorOutput) {
+ result.authorOutput = authorOutput
+ }
+ if (platformOutput) {
+ result.platformOutput = platformOutput
+ }
+ return result
+}
+
+export function buildVerificationResult(
+ valid: boolean,
+ confirmed: boolean,
+ confirmations: number,
+ authorOutput?: MempoolTransaction['vout'][0],
+ platformOutput?: MempoolTransaction['vout'][0]
+): TransactionVerificationResult {
+ const result: TransactionVerificationResult = {
+ valid,
+ confirmed,
+ confirmations,
+ }
+
+ if (!valid) {
+ result.error = 'Transaction outputs do not match expected split'
+ }
+
+ if (authorOutput) {
+ result.authorOutput = {
+ address: authorOutput.scriptpubkey_address,
+ amount: authorOutput.value,
+ }
+ }
+
+ if (platformOutput) {
+ result.platformOutput = {
+ address: platformOutput.scriptpubkey_address,
+ amount: platformOutput.value,
+ }
+ }
+
+ return result
+}
+
+export async function validateTransactionOutputs(
+ transaction: MempoolTransaction,
+ authorMainnetAddress: string,
+ split: { authorSats: number; platformSats: number }
+): Promise<{ valid: boolean; confirmed: boolean; confirmations: number; authorOutput?: MempoolTransaction['vout'][0]; platformOutput?: MempoolTransaction['vout'][0] }> {
+ const { authorOutput, platformOutput } = findTransactionOutputs(
+ transaction,
+ authorMainnetAddress,
+ split.authorSats,
+ split.platformSats
+ )
+
+ const valid = Boolean(authorOutput && platformOutput)
+ const confirmed = transaction.status.confirmed
+ const confirmations = confirmed && transaction.status.block_height
+ ? await getConfirmations(transaction.status.block_height)
+ : 0
+
+ const result: { valid: boolean; confirmed: boolean; confirmations: number; authorOutput?: MempoolTransaction['vout'][0]; platformOutput?: MempoolTransaction['vout'][0] } = {
+ valid,
+ confirmed,
+ confirmations,
+ }
+ if (authorOutput) {
+ result.authorOutput = authorOutput
+ }
+ if (platformOutput) {
+ result.platformOutput = platformOutput
+ }
+ return result
+}
+
+export function logVerificationFailure(
+ txid: string,
+ authorMainnetAddress: string,
+ split: { authorSats: number; platformSats: number },
+ transaction: MempoolTransaction
+): void {
+ console.error('Transaction verification failed', {
+ txid,
+ authorAddress: authorMainnetAddress,
+ platformAddress: PLATFORM_BITCOIN_ADDRESS,
+ expectedAuthorAmount: split.authorSats,
+ expectedPlatformAmount: split.platformSats,
+ actualOutputs: transaction.vout.map((o) => ({
+ address: o.scriptpubkey_address,
+ amount: o.value,
+ })),
+ timestamp: new Date().toISOString(),
+ })
+}
+
+export function handleVerificationError(txid: string, authorMainnetAddress: string, error: unknown): TransactionVerificationResult {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error'
+ console.error('Error verifying sponsoring transaction', {
+ txid,
+ authorAddress: authorMainnetAddress,
+ error: errorMessage,
+ timestamp: new Date().toISOString(),
+ })
+ return {
+ valid: false,
+ confirmed: false,
+ confirmations: 0,
+ error: errorMessage,
+ }
+}
+
+export async function verifySponsoringTransaction(
+ txid: string,
+ authorMainnetAddress: string
+): Promise {
+ try {
+ const transaction = await getTransaction(txid)
+
+ if (!transaction) {
+ return {
+ valid: false,
+ confirmed: false,
+ confirmations: 0,
+ error: 'Transaction not found',
+ }
+ }
+
+ const split = calculateSponsoringSplit()
+ const validation = await validateTransactionOutputs(transaction, authorMainnetAddress, split)
+
+ if (!validation.valid) {
+ logVerificationFailure(txid, authorMainnetAddress, split, transaction)
+ }
+
+ return buildVerificationResult(
+ validation.valid,
+ validation.confirmed,
+ validation.confirmations,
+ validation.authorOutput,
+ validation.platformOutput
+ )
+ } catch (error) {
+ return handleVerificationError(txid, authorMainnetAddress, error)
+ }
+}
diff --git a/lib/nip95.ts b/lib/nip95.ts
index e6c29d6..9274f45 100644
--- a/lib/nip95.ts
+++ b/lib/nip95.ts
@@ -1,4 +1,5 @@
import type { MediaRef } from '@/types/nostr'
+import { getPrimaryNip95Api } from './config'
const MAX_IMAGE_BYTES = 5 * 1024 * 1024
const MAX_VIDEO_BYTES = 45 * 1024 * 1024
@@ -36,10 +37,10 @@ export async function uploadNip95Media(file: File): Promise {
assertBrowser()
const mediaType = validateFile(file)
- const endpoint = process.env.NEXT_PUBLIC_NIP95_UPLOAD_URL
+ const endpoint = await getPrimaryNip95Api()
if (!endpoint) {
throw new Error(
- 'NIP-95 upload endpoint is not configured. Please set NEXT_PUBLIC_NIP95_UPLOAD_URL environment variable. See README.md for setup instructions.'
+ 'NIP-95 upload endpoint is not configured. Please configure a NIP-95 API endpoint in the application settings.'
)
}
diff --git a/lib/nostr.ts b/lib/nostr.ts
index e81d913..d7e056f 100644
--- a/lib/nostr.ts
+++ b/lib/nostr.ts
@@ -9,8 +9,8 @@ import {
} from './nostrPrivateMessages'
import { checkZapReceipt as checkZapReceiptHelper } from './nostrZapVerification'
import { subscribeWithTimeout } from './nostrSubscription'
-
-const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
+import { getPrimaryRelay, getPrimaryRelaySync } from './config'
+import { buildTagFilter } from './nostrTagSystem'
class NostrService {
private pool: SimplePool | null = null
@@ -77,7 +77,8 @@ class NostrService {
} as Event
try {
- const pubs = this.pool.publish([RELAY_URL], event)
+ const relayUrl = await getPrimaryRelay()
+ const pubs = this.pool.publish([relayUrl], event)
await Promise.all(pubs)
return event
} catch (e) {
@@ -85,6 +86,19 @@ class NostrService {
}
}
+ private createArticleSubscription(pool: SimplePoolWithSub, limit: number) {
+ const filters = [
+ {
+ ...buildTagFilter({
+ type: 'publication',
+ }),
+ limit,
+ },
+ ]
+ const relayUrl = getPrimaryRelaySync()
+ return pool.sub([relayUrl], filters)
+ }
+
subscribeToArticles(callback: (article: Article) => void, limit: number = 100): () => void {
if (typeof window === 'undefined') {
throw new Error('Cannot subscribe on server side')
@@ -98,20 +112,8 @@ class NostrService {
throw new Error('Pool not initialized')
}
- // Use new tag system to filter publications
- // Import synchronously since this is not async
- const { buildTagFilter } = require('./nostrTagSystem')
- const filters = [
- {
- ...buildTagFilter({
- type: 'publication',
- }),
- limit,
- },
- ]
-
const pool = this.pool as SimplePoolWithSub
- const sub = pool.sub([RELAY_URL], filters)
+ const sub = this.createArticleSubscription(pool, limit)
sub.on('event', (event: Event) => {
try {
@@ -152,39 +154,32 @@ class NostrService {
* then retrieves the decryption key from private messages,
* and finally decrypts the content
*/
+ private async retrieveDecryptionKey(eventId: string, authorPubkey: string): Promise<{ key: string; iv: string } | null> {
+ if (!this.privateKey || !this.pool || !this.publicKey) {
+ return null
+ }
+ return await getDecryptionKey(this.pool, eventId, authorPubkey, this.privateKey, this.publicKey)
+ }
+
async getDecryptedArticleContent(eventId: string, authorPubkey: string): Promise {
if (!this.privateKey || !this.pool || !this.publicKey) {
throw new Error('Private key not set or pool not initialized')
}
try {
- // Get the raw event to retrieve the encrypted content
const event = await this.getEventById(eventId)
if (!event) {
console.error('Event not found', { eventId, authorPubkey })
return null
}
- const encryptedContent = event.content
-
- // Try to get the decryption key from private messages
- const decryptionKey = await getDecryptionKey(
- this.pool,
- eventId,
- authorPubkey,
- this.privateKey,
- this.publicKey
- )
-
+ const decryptionKey = await this.retrieveDecryptionKey(eventId, authorPubkey)
if (!decryptionKey) {
console.warn('Decryption key not found in private messages', { eventId, authorPubkey })
return null
}
- // Decrypt the content using the key
- const decryptedContent = await decryptArticleContentWithKey(encryptedContent, decryptionKey)
-
- return decryptedContent
+ return await decryptArticleContentWithKey(event.content, decryptionKey)
} catch (error) {
console.error('Error decrypting article content', {
eventId,
@@ -239,6 +234,8 @@ class NostrService {
throw new Error('Private key not set')
}
+ const relayUrl = await getPrimaryRelay()
+
const zapRequest: EventTemplate = {
kind: 9734, // Zap request
created_at: Math.floor(Date.now() / 1000),
@@ -246,7 +243,7 @@ class NostrService {
['p', targetPubkey],
['e', targetEventId],
['amount', amount.toString()],
- ['relays', RELAY_URL],
+ ['relays', relayUrl],
],
content: '',
}
diff --git a/lib/nostrAuth.ts b/lib/nostrAuth.ts
index 6728ece..8cb39c4 100644
--- a/lib/nostrAuth.ts
+++ b/lib/nostrAuth.ts
@@ -1,9 +1,10 @@
import { nostrService } from './nostr'
+import { keyManagementService } from './keyManagement'
import type { NostrConnectState } from '@/types/nostr'
/**
- * Nostr authentication service using Alby (NIP-07)
- * Alby exposes window.nostr API for Nostr authentication and signing
+ * Nostr authentication service using local key management
+ * Keys are stored encrypted in IndexedDB and decrypted using recovery phrase
*/
export class NostrAuthService {
private state: NostrConnectState = {
@@ -13,6 +14,7 @@ export class NostrAuthService {
}
private listeners: Set<(state: NostrConnectState) => void> = new Set()
+ private unlockedPrivateKey: string | null = null
constructor() {
if (typeof window !== 'undefined') {
@@ -34,55 +36,128 @@ export class NostrAuthService {
}
/**
- * Check if Alby (window.nostr) is available
+ * Check if account exists
*/
- isAvailable(): boolean {
- return typeof window !== 'undefined' && typeof window.nostr !== 'undefined'
+ async accountExists(): Promise {
+ return keyManagementService.accountExists()
}
/**
- * Connect using Alby (NIP-07)
+ * Create a new account (generate or import key)
+ * Returns recovery phrase and npub
*/
- async connect(): Promise {
- if (!this.isAvailable()) {
- throw new Error('Alby extension not available. Please install Alby browser extension.')
- }
+ async createAccount(privateKey?: string): Promise<{
+ recoveryPhrase: string[]
+ npub: string
+ publicKey: string
+ }> {
+ const result = await keyManagementService.createAccount(privateKey)
- if (!window.nostr) {
- throw new Error('window.nostr is not available. Please ensure Alby extension is installed and enabled.')
+ // Set public key immediately
+ this.state = {
+ connected: false,
+ pubkey: result.publicKey,
+ profile: null,
}
+ nostrService.setPublicKey(result.publicKey)
+ this.saveStateToStorage()
+ this.notifyListeners()
+ return result
+ }
+
+ /**
+ * Unlock account using recovery phrase
+ */
+ async unlockAccount(recoveryPhrase: string[]): Promise {
try {
- const pubkey = await window.nostr.getPublicKey()
- if (!pubkey) {
- throw new Error('Failed to get public key from Alby')
- }
+ const keys = await keyManagementService.unlockAccount(recoveryPhrase)
+ this.unlockedPrivateKey = keys.privateKey
this.state = {
connected: true,
- pubkey,
+ pubkey: keys.publicKey,
profile: null,
}
- nostrService.setPublicKey(pubkey)
+
+ nostrService.setPublicKey(keys.publicKey)
+ nostrService.setPrivateKey(keys.privateKey)
this.saveStateToStorage()
this.notifyListeners()
void this.loadProfile()
} catch (e) {
- console.error('Error connecting with Alby:', e)
- throw new Error(`Failed to connect with Alby: ${e instanceof Error ? e.message : 'Unknown error'}`)
+ console.error('Error unlocking account:', e)
+ throw new Error(`Failed to unlock account: ${e instanceof Error ? e.message : 'Unknown error'}`)
}
}
+ /**
+ * Connect using existing stored keys (if already unlocked)
+ * This is called when the app loads and keys are already available
+ */
+ async connect(): Promise {
+ // Check if account exists
+ const exists = await keyManagementService.accountExists()
+ if (!exists) {
+ throw new Error('No account found. Please create an account first.')
+ }
+
+ // Try to get public keys
+ const publicKeys = await keyManagementService.getPublicKeys()
+ if (!publicKeys) {
+ throw new Error('Account exists but public keys not found')
+ }
+
+ // Set public key but don't unlock private key yet
+ // Private key will be unlocked when user provides recovery phrase
+ this.state = {
+ connected: false,
+ pubkey: publicKeys.publicKey,
+ profile: null,
+ }
+ nostrService.setPublicKey(publicKeys.publicKey)
+ this.saveStateToStorage()
+ this.notifyListeners()
+ void this.loadProfile()
+ }
+
+ /**
+ * Get the private key if unlocked
+ */
+ getPrivateKey(): string | null {
+ return this.unlockedPrivateKey
+ }
+
+ /**
+ * Check if private key is unlocked
+ */
+ isUnlocked(): boolean {
+ return this.unlockedPrivateKey !== null
+ }
+
disconnect(): void {
+ this.unlockedPrivateKey = null
this.state = {
connected: false,
pubkey: null,
profile: null,
}
+ // Clear keys from nostrService - the service stores keys internally, we just clear our reference
+ // The service will continue to work but won't have access to the keys
+ nostrService.setPrivateKey('')
+ nostrService.setPublicKey('')
this.saveStateToStorage()
this.notifyListeners()
}
+ /**
+ * Delete account (remove all stored keys)
+ */
+ async deleteAccount(): Promise {
+ await keyManagementService.deleteAccount()
+ this.disconnect()
+ }
+
private async loadProfile(): Promise {
if (!this.state.pubkey) {
return
@@ -121,6 +196,7 @@ export class NostrAuthService {
if (this.state.pubkey) {
nostrService.setPublicKey(this.state.pubkey)
}
+ // Note: private key is not stored, it must be unlocked with recovery phrase
}
} catch (e) {
console.error('Error loading state from storage:', e)
@@ -129,7 +205,13 @@ export class NostrAuthService {
private saveStateToStorage(): void {
try {
- localStorage.setItem('nostr_auth_state', JSON.stringify(this.state))
+ // Only save public information, never private keys
+ const stateToSave = {
+ connected: this.state.connected,
+ pubkey: this.state.pubkey,
+ profile: this.state.profile,
+ }
+ localStorage.setItem('nostr_auth_state', JSON.stringify(stateToSave))
} catch (e) {
console.error('Error saving state to storage:', e)
}
diff --git a/lib/nostrEventParsing.ts b/lib/nostrEventParsing.ts
index 6658f30..1133c75 100644
--- a/lib/nostrEventParsing.ts
+++ b/lib/nostrEventParsing.ts
@@ -99,20 +99,20 @@ function buildArticle(event: Event, tags: ReturnType {
let resolved = false
- const sub = pool.sub([RELAY_URL], createPrivateMessageFilters(eventId, publicKey, authorPubkey))
+ const relayUrl = getPrimaryRelaySync()
+ const sub = pool.sub([relayUrl], createPrivateMessageFilters(eventId, publicKey, authorPubkey))
const finalize = (result: string | null) => {
if (resolved) {
@@ -70,6 +70,37 @@ export function getPrivateContent(
* Get decryption key for an article from private messages
* Returns the decryption key and IV if found
*/
+function parseDecryptionKey(decryptedContent: string): DecryptionKey | null {
+ try {
+ const keyData = JSON.parse(decryptedContent) as DecryptionKey
+ if (keyData.key && keyData.iv) {
+ return keyData
+ }
+ } catch {
+ // If parsing fails, it might be old format (full content)
+ }
+ return null
+}
+
+function handleDecryptionKeyEvent(
+ event: Event,
+ recipientPrivateKey: string,
+ finalize: (result: DecryptionKey | null) => void
+): void {
+ void decryptContent(recipientPrivateKey, event)
+ .then((decryptedContent) => {
+ if (decryptedContent) {
+ const keyData = parseDecryptionKey(decryptedContent)
+ if (keyData) {
+ finalize(keyData)
+ }
+ }
+ })
+ .catch((e) => {
+ console.error('Error decrypting decryption key:', e)
+ })
+}
+
export async function getDecryptionKey(
pool: SimplePool,
eventId: string,
@@ -83,7 +114,8 @@ export async function getDecryptionKey(
return new Promise((resolve) => {
let resolved = false
- const sub = pool.sub([RELAY_URL], createPrivateMessageFilters(eventId, recipientPublicKey, authorPubkey))
+ const relayUrl = getPrimaryRelaySync()
+ const sub = pool.sub([relayUrl], createPrivateMessageFilters(eventId, recipientPublicKey, authorPubkey))
const finalize = (result: DecryptionKey | null) => {
if (resolved) {
@@ -94,25 +126,8 @@ export async function getDecryptionKey(
resolve(result)
}
- sub.on('event', async (event: Event) => {
- try {
- const decryptedContent = await decryptContent(recipientPrivateKey, event)
- if (decryptedContent) {
- try {
- // Try to parse as decryption key (new format)
- const keyData = JSON.parse(decryptedContent) as DecryptionKey
- if (keyData.key && keyData.iv) {
- finalize(keyData)
- return
- }
- } catch {
- // If parsing fails, it might be old format (full content)
- // Return null to indicate we need to use the old method
- }
- }
- } catch (e) {
- console.error('Error decrypting decryption key:', e)
- }
+ sub.on('event', (event: Event) => {
+ handleDecryptionKeyEvent(event, recipientPrivateKey, finalize)
})
sub.on('eose', () => finalize(null))
setTimeout(() => finalize(null), 5000)
diff --git a/lib/nostrRemoteSigner.ts b/lib/nostrRemoteSigner.ts
index 9f214ff..5499c98 100644
--- a/lib/nostrRemoteSigner.ts
+++ b/lib/nostrRemoteSigner.ts
@@ -11,52 +11,58 @@ export class NostrRemoteSigner {
/**
* Sign an event template using Alby (window.nostr)
*/
+ private buildUnsignedEvent(eventTemplate: EventTemplate, pubkey: string): EventTemplate & { pubkey: string } {
+ return {
+ pubkey,
+ ...eventTemplate,
+ created_at: eventTemplate.created_at ?? Math.floor(Date.now() / 1000),
+ }
+ }
+
+ private async signWithAlby(unsignedEvent: EventTemplate & { pubkey: string }): Promise {
+ if (typeof window === 'undefined' || !window.nostr) {
+ return null
+ }
+ try {
+ const signedEvent = await window.nostr.signEvent({
+ kind: unsignedEvent.kind,
+ created_at: unsignedEvent.created_at,
+ tags: unsignedEvent.tags,
+ content: unsignedEvent.content,
+ })
+ return signedEvent as Event
+ } catch (e) {
+ console.error('Error signing with Alby:', e)
+ throw new Error('Failed to sign event with Alby extension')
+ }
+ }
+
+ private signWithPrivateKey(unsignedEvent: EventTemplate & { pubkey: string }): Event {
+ const privateKey = nostrService.getPrivateKey()
+ if (!privateKey) {
+ throw new Error('Alby extension required for signing. Please install and connect Alby browser extension.')
+ }
+ const eventId = getEventHash(unsignedEvent)
+ return {
+ ...unsignedEvent,
+ id: eventId,
+ sig: signEvent(unsignedEvent, privateKey),
+ } as Event
+ }
+
async signEvent(eventTemplate: EventTemplate): Promise {
- // Get the event hash first
const pubkey = nostrService.getPublicKey()
if (!pubkey) {
throw new Error('Public key required for signing. Please connect with Alby.')
}
- const unsignedEvent = {
- pubkey,
- ...eventTemplate,
- created_at: eventTemplate.created_at ?? Math.floor(Date.now() / 1000),
- }
- const eventId = getEventHash(unsignedEvent)
-
- // Use Alby (window.nostr) for signing
- if (typeof window !== 'undefined' && window.nostr) {
- try {
- const signedEvent = await window.nostr.signEvent({
- kind: unsignedEvent.kind,
- created_at: unsignedEvent.created_at,
- tags: unsignedEvent.tags,
- content: unsignedEvent.content,
- })
- return signedEvent as Event
- } catch (e) {
- console.error('Error signing with Alby:', e)
- throw new Error('Failed to sign event with Alby extension')
- }
+ const unsignedEvent = this.buildUnsignedEvent(eventTemplate, pubkey)
+ const albySigned = await this.signWithAlby(unsignedEvent)
+ if (albySigned) {
+ return albySigned
}
- // Fallback to private key signing (should not happen if Alby is properly connected)
- const privateKey = nostrService.getPrivateKey()
- if (!privateKey) {
- throw new Error(
- 'Alby extension required for signing. ' +
- 'Please install and connect Alby browser extension.'
- )
- }
-
- const event = {
- ...unsignedEvent,
- id: eventId,
- sig: signEvent(unsignedEvent, privateKey),
- } as Event
-
- return event
+ return this.signWithPrivateKey(unsignedEvent)
}
/**
diff --git a/lib/nostrSubscription.ts b/lib/nostrSubscription.ts
index dee5607..8f85574 100644
--- a/lib/nostrSubscription.ts
+++ b/lib/nostrSubscription.ts
@@ -1,7 +1,6 @@
import type { Event, Filter } from 'nostr-tools'
import { SimplePool } from 'nostr-tools'
-
-const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
+import { getPrimaryRelaySync } from './config'
/**
* Subscribe to events with timeout
@@ -14,7 +13,8 @@ export function subscribeWithTimeout(
): Promise {
return new Promise((resolve) => {
const resolved = { value: false }
- const sub = pool.sub([RELAY_URL], filters)
+ const relayUrl = getPrimaryRelaySync()
+ const sub = pool.sub([relayUrl], filters)
let timeoutId: NodeJS.Timeout | null = null
const cleanup = () => {
diff --git a/lib/nostrTagSystem.ts b/lib/nostrTagSystem.ts
index e2f5a79..97b065d 100644
--- a/lib/nostrTagSystem.ts
+++ b/lib/nostrTagSystem.ts
@@ -1,274 +1,4 @@
-/**
- * New tag system based on:
- * - #paywall: for paid publications
- * - #sciencefiction or #research: for category
- * - #author, #series, #publication, #quote: for type
- * - #id_: for identifier
- * - #payment (optional): for payment notes
- *
- * Everything is a Nostr note (kind 1)
- * All tags are in English
- */
-
-export type TagType = 'author' | 'series' | 'publication' | 'quote'
-export type TagCategory = 'sciencefiction' | 'research'
-
-export interface BaseTags {
- type: TagType
- category: TagCategory
- id: string
- paywall?: boolean
- payment?: boolean
-}
-
-export interface AuthorTags extends BaseTags {
- type: 'author'
- title: string
- preview?: string
- mainnetAddress?: string
- totalSponsoring?: number
- pictureUrl?: string
-}
-
-export interface SeriesTags extends BaseTags {
- type: 'series'
- title: string
- description: string
- preview?: string
- coverUrl?: string
-}
-
-export interface PublicationTags extends BaseTags {
- type: 'publication'
- title: string
- preview?: string
- seriesId?: string
- bannerUrl?: string
- zapAmount?: number
- invoice?: string
- paymentHash?: string
- encryptedKey?: string
-}
-
-export interface QuoteTags extends BaseTags {
- type: 'quote'
- articleId: string
- reviewerPubkey?: string
- title?: string
-}
-
-/**
- * Build tags array from tag object
- * Tags format: ['tag_name'] for simple tags, ['tag_name', 'value'] for tags with values
- */
-export function buildTags(tags: AuthorTags | SeriesTags | PublicationTags | QuoteTags): string[][] {
- const result: string[][] = []
-
- // Type tag (required) - simple tag without value
- result.push([tags.type])
-
- // Category tag (required) - simple tag without value
- result.push([tags.category])
-
- // ID tag (required) - tag with value: ['id', '']
- result.push(['id', tags.id])
-
- // Paywall tag (optional) - simple tag without value
- if (tags.paywall) {
- result.push(['paywall'])
- }
-
- // Payment tag (optional) - simple tag without value
- if (tags.payment) {
- result.push(['payment'])
- }
-
- // Type-specific tags
- if (tags.type === 'author') {
- const authorTags = tags as AuthorTags
- result.push(['title', authorTags.title])
- if (authorTags.preview) {
- result.push(['preview', authorTags.preview])
- }
- if (authorTags.mainnetAddress) {
- result.push(['mainnet_address', authorTags.mainnetAddress])
- }
- if (authorTags.totalSponsoring !== undefined) {
- result.push(['total_sponsoring', authorTags.totalSponsoring.toString()])
- }
- if (authorTags.pictureUrl) {
- result.push(['picture', authorTags.pictureUrl])
- }
- } else if (tags.type === 'series') {
- const seriesTags = tags as SeriesTags
- result.push(['title', seriesTags.title])
- result.push(['description', seriesTags.description])
- if (seriesTags.preview) {
- result.push(['preview', seriesTags.preview])
- }
- if (seriesTags.coverUrl) {
- result.push(['cover', seriesTags.coverUrl])
- }
- } else if (tags.type === 'publication') {
- const pubTags = tags as PublicationTags
- result.push(['title', pubTags.title])
- if (pubTags.preview) {
- result.push(['preview', pubTags.preview])
- }
- if (pubTags.seriesId) {
- result.push(['series', pubTags.seriesId])
- }
- if (pubTags.bannerUrl) {
- result.push(['banner', pubTags.bannerUrl])
- }
- if (pubTags.zapAmount) {
- result.push(['zap', pubTags.zapAmount.toString()])
- }
- if (pubTags.invoice) {
- result.push(['invoice', pubTags.invoice])
- }
- if (pubTags.paymentHash) {
- result.push(['payment_hash', pubTags.paymentHash])
- }
- if (pubTags.encryptedKey) {
- result.push(['encrypted_key', pubTags.encryptedKey])
- }
- } else if (tags.type === 'quote') {
- const quoteTags = tags as QuoteTags
- result.push(['article', quoteTags.articleId])
- if (quoteTags.reviewerPubkey) {
- result.push(['reviewer', quoteTags.reviewerPubkey])
- }
- if (quoteTags.title) {
- result.push(['title', quoteTags.title])
- }
- }
-
- return result
-}
-
-/**
- * Extract tags from event
- */
-export function extractTagsFromEvent(event: { tags: string[][] }): {
- type?: TagType | undefined
- category?: TagCategory | undefined
- id?: string | undefined
- paywall: boolean
- payment: boolean
- title?: string | undefined
- preview?: string | undefined
- description?: string | undefined
- mainnetAddress?: string | undefined
- totalSponsoring?: number | undefined
- seriesId?: string | undefined
- coverUrl?: string | undefined
- bannerUrl?: string | undefined
- zapAmount?: number | undefined
- invoice?: string | undefined
- paymentHash?: string | undefined
- articleId?: string | undefined
- reviewerPubkey?: string | undefined
- [key: string]: unknown
-} {
- const findTag = (key: string) => event.tags.find((tag) => tag[0] === key)?.[1]
- const hasTag = (key: string) => event.tags.some((tag) => tag[0] === key || (tag.length === 1 && tag[0] === key))
-
- const type = event.tags.find((tag) => tag.length === 1 && tag[0] && ['author', 'series', 'publication', 'quote'].includes(tag[0]))?.[0] as TagType | undefined
- const category = event.tags.find((tag) => tag.length === 1 && tag[0] && ['sciencefiction', 'research'].includes(tag[0]))?.[0] as TagCategory | undefined
- const id = findTag('id')
-
- return {
- type,
- category,
- id,
- paywall: hasTag('paywall'),
- payment: hasTag('payment'),
- // Extract all other tags
- title: findTag('title'),
- preview: findTag('preview'),
- description: findTag('description'),
- mainnetAddress: findTag('mainnet_address'),
- totalSponsoring: (() => {
- const val = findTag('total_sponsoring')
- return val ? parseInt(val, 10) : undefined
- })(),
- pictureUrl: findTag('picture'),
- seriesId: findTag('series'),
- coverUrl: findTag('cover'),
- bannerUrl: findTag('banner'),
- zapAmount: (() => {
- const val = findTag('zap')
- return val ? parseInt(val, 10) : undefined
- })(),
- invoice: findTag('invoice'),
- paymentHash: findTag('payment_hash'),
- encryptedKey: findTag('encrypted_key'),
- articleId: findTag('article'),
- reviewerPubkey: findTag('reviewer'),
- }
-}
-
-/**
- * Build Nostr filter for querying by tags
- * Nostr filters use #tag for tag-based filtering
- */
-export function buildTagFilter(params: {
- type?: TagType
- category?: TagCategory
- id?: string
- paywall?: boolean
- payment?: boolean
- seriesId?: string
- articleId?: string
- authorPubkey?: string
-}): Record {
- const filter: Record = {
- kinds: [1], // All are kind 1 notes
- }
-
- // Type tag filter (simple tag without value)
- if (params.type) {
- filter[`#${params.type}`] = ['']
- }
-
- // Category tag filter (simple tag without value)
- if (params.category) {
- filter[`#${params.category}`] = ['']
- }
-
- // ID tag filter (tag with value)
- if (params.id) {
- filter['#id'] = [params.id]
- } else {
- // If no ID specified, we still need to ensure the filter structure is valid
- // Nostr filters require at least one valid filter property
- }
-
- // Paywall tag filter (simple tag without value)
- if (params.paywall) {
- filter['#paywall'] = ['']
- }
-
- // Payment tag filter (simple tag without value)
- if (params.payment) {
- filter['#payment'] = ['']
- }
-
- // Series ID filter (tag with value)
- if (params.seriesId) {
- filter['#series'] = [params.seriesId]
- }
-
- // Article ID filter (tag with value)
- if (params.articleId) {
- filter['#article'] = [params.articleId]
- }
-
- // Author pubkey filter
- if (params.authorPubkey) {
- filter.authors = [params.authorPubkey]
- }
-
- return filter
-}
+export type { TagType, TagCategory, BaseTags, AuthorTags, SeriesTags, PublicationTags, QuoteTags } from './nostrTagSystemTypes'
+export { buildTags } from './nostrTagSystemBuild'
+export { extractTagsFromEvent } from './nostrTagSystemExtract'
+export { buildTagFilter } from './nostrTagSystemFilter'
diff --git a/lib/nostrTagSystemBuild.ts b/lib/nostrTagSystemBuild.ts
new file mode 100644
index 0000000..a804c11
--- /dev/null
+++ b/lib/nostrTagSystemBuild.ts
@@ -0,0 +1,93 @@
+import type { AuthorTags, SeriesTags, PublicationTags, QuoteTags } from './nostrTagSystemTypes'
+
+function buildBaseTags(tags: AuthorTags | SeriesTags | PublicationTags | QuoteTags): string[][] {
+ const result: string[][] = []
+ result.push([tags.type])
+ result.push([tags.category])
+ result.push(['id', tags.id])
+ if (tags.paywall) {
+ result.push(['paywall'])
+ }
+ if (tags.payment) {
+ result.push(['payment'])
+ }
+ return result
+}
+
+export function buildAuthorTags(authorTags: AuthorTags, result: string[][]): void {
+ result.push(['title', authorTags.title])
+ if (authorTags.preview) {
+ result.push(['preview', authorTags.preview])
+ }
+ if (authorTags.mainnetAddress) {
+ result.push(['mainnet_address', authorTags.mainnetAddress])
+ }
+ if (authorTags.totalSponsoring !== undefined) {
+ result.push(['total_sponsoring', authorTags.totalSponsoring.toString()])
+ }
+ if (authorTags.pictureUrl) {
+ result.push(['picture', authorTags.pictureUrl])
+ }
+}
+
+export function buildSeriesTags(seriesTags: SeriesTags, result: string[][]): void {
+ result.push(['title', seriesTags.title])
+ result.push(['description', seriesTags.description])
+ if (seriesTags.preview) {
+ result.push(['preview', seriesTags.preview])
+ }
+ if (seriesTags.coverUrl) {
+ result.push(['cover', seriesTags.coverUrl])
+ }
+}
+
+export function buildPublicationTags(pubTags: PublicationTags, result: string[][]): void {
+ result.push(['title', pubTags.title])
+ if (pubTags.preview) {
+ result.push(['preview', pubTags.preview])
+ }
+ if (pubTags.seriesId) {
+ result.push(['series', pubTags.seriesId])
+ }
+ if (pubTags.bannerUrl) {
+ result.push(['banner', pubTags.bannerUrl])
+ }
+ if (pubTags.zapAmount) {
+ result.push(['zap', pubTags.zapAmount.toString()])
+ }
+ if (pubTags.invoice) {
+ result.push(['invoice', pubTags.invoice])
+ }
+ if (pubTags.paymentHash) {
+ result.push(['payment_hash', pubTags.paymentHash])
+ }
+ if (pubTags.encryptedKey) {
+ result.push(['encrypted_key', pubTags.encryptedKey])
+ }
+}
+
+export function buildQuoteTags(quoteTags: QuoteTags, result: string[][]): void {
+ result.push(['article', quoteTags.articleId])
+ if (quoteTags.reviewerPubkey) {
+ result.push(['reviewer', quoteTags.reviewerPubkey])
+ }
+ if (quoteTags.title) {
+ result.push(['title', quoteTags.title])
+ }
+}
+
+export function buildTags(tags: AuthorTags | SeriesTags | PublicationTags | QuoteTags): string[][] {
+ const result = buildBaseTags(tags)
+
+ if (tags.type === 'author') {
+ buildAuthorTags(tags as AuthorTags, result)
+ } else if (tags.type === 'series') {
+ buildSeriesTags(tags as SeriesTags, result)
+ } else if (tags.type === 'publication') {
+ buildPublicationTags(tags as PublicationTags, result)
+ } else if (tags.type === 'quote') {
+ buildQuoteTags(tags as QuoteTags, result)
+ }
+
+ return result
+}
diff --git a/lib/nostrTagSystemExtract.ts b/lib/nostrTagSystemExtract.ts
new file mode 100644
index 0000000..ac2dbba
--- /dev/null
+++ b/lib/nostrTagSystemExtract.ts
@@ -0,0 +1,75 @@
+import type { TagType, TagCategory } from './nostrTagSystemTypes'
+
+export function parseNumericTag(findTag: (key: string) => string | undefined, key: string): number | undefined {
+ const val = findTag(key)
+ return val ? parseInt(val, 10) : undefined
+}
+
+export function extractTypeAndCategory(event: { tags: string[][] }): { type?: TagType; category?: TagCategory } {
+ const typeValue = event.tags.find((tag) => tag.length === 1 && tag[0] && ['author', 'series', 'publication', 'quote'].includes(tag[0]))?.[0] as TagType | undefined
+ const categoryValue = event.tags.find((tag) => tag.length === 1 && tag[0] && ['sciencefiction', 'research'].includes(tag[0]))?.[0] as TagCategory | undefined
+ const result: { type?: TagType; category?: TagCategory } = {}
+ if (typeValue) {
+ result.type = typeValue
+ }
+ if (categoryValue) {
+ result.category = categoryValue
+ }
+ return result
+}
+
+export function extractCommonTags(findTag: (key: string) => string | undefined, hasTag: (key: string) => boolean) {
+ return {
+ id: findTag('id'),
+ paywall: hasTag('paywall'),
+ payment: hasTag('payment'),
+ title: findTag('title'),
+ preview: findTag('preview'),
+ description: findTag('description'),
+ mainnetAddress: findTag('mainnet_address'),
+ totalSponsoring: parseNumericTag(findTag, 'total_sponsoring'),
+ pictureUrl: findTag('picture'),
+ seriesId: findTag('series'),
+ coverUrl: findTag('cover'),
+ bannerUrl: findTag('banner'),
+ zapAmount: parseNumericTag(findTag, 'zap'),
+ invoice: findTag('invoice'),
+ paymentHash: findTag('payment_hash'),
+ encryptedKey: findTag('encrypted_key'),
+ articleId: findTag('article'),
+ reviewerPubkey: findTag('reviewer'),
+ }
+}
+
+export function extractTagsFromEvent(event: { tags: string[][] }): {
+ type?: TagType | undefined
+ category?: TagCategory | undefined
+ id?: string | undefined
+ paywall: boolean
+ payment: boolean
+ title?: string | undefined
+ preview?: string | undefined
+ description?: string | undefined
+ mainnetAddress?: string | undefined
+ totalSponsoring?: number | undefined
+ seriesId?: string | undefined
+ coverUrl?: string | undefined
+ bannerUrl?: string | undefined
+ zapAmount?: number | undefined
+ invoice?: string | undefined
+ paymentHash?: string | undefined
+ articleId?: string | undefined
+ reviewerPubkey?: string | undefined
+ [key: string]: unknown
+} {
+ const findTag = (key: string) => event.tags.find((tag) => tag[0] === key)?.[1]
+ const hasTag = (key: string) => event.tags.some((tag) => tag[0] === key || (tag.length === 1 && tag[0] === key))
+ const typeCategory = extractTypeAndCategory(event)
+ const commonTags = extractCommonTags(findTag, hasTag)
+
+ return {
+ ...(typeCategory.type ? { type: typeCategory.type } : {}),
+ ...(typeCategory.category ? { category: typeCategory.category } : {}),
+ ...commonTags,
+ }
+}
diff --git a/lib/nostrTagSystemFilter.ts b/lib/nostrTagSystemFilter.ts
new file mode 100644
index 0000000..0c716f9
--- /dev/null
+++ b/lib/nostrTagSystemFilter.ts
@@ -0,0 +1,46 @@
+import type { TagType, TagCategory } from './nostrTagSystemTypes'
+
+export function addSimpleTagFilter(filter: Record, tagName: string, condition: boolean): void {
+ if (condition) {
+ filter[`#${tagName}`] = ['']
+ }
+}
+
+export function addValueTagFilter(filter: Record, tagName: string, value: string | undefined): void {
+ if (value) {
+ filter[`#${tagName}`] = [value]
+ }
+}
+
+export function buildTagFilter(params: {
+ type?: TagType
+ category?: TagCategory
+ id?: string
+ paywall?: boolean
+ payment?: boolean
+ seriesId?: string
+ articleId?: string
+ authorPubkey?: string
+}): Record {
+ const filter: Record = {
+ kinds: [1], // All are kind 1 notes
+ }
+
+ if (params.type) {
+ filter[`#${params.type}`] = ['']
+ }
+ if (params.category) {
+ filter[`#${params.category}`] = ['']
+ }
+ addValueTagFilter(filter, 'id', params.id)
+ addSimpleTagFilter(filter, 'paywall', params.paywall === true)
+ addSimpleTagFilter(filter, 'payment', params.payment === true)
+ addValueTagFilter(filter, 'series', params.seriesId)
+ addValueTagFilter(filter, 'article', params.articleId)
+
+ if (params.authorPubkey) {
+ filter.authors = [params.authorPubkey]
+ }
+
+ return filter
+}
diff --git a/lib/nostrTagSystemTypes.ts b/lib/nostrTagSystemTypes.ts
new file mode 100644
index 0000000..6c5be2a
--- /dev/null
+++ b/lib/nostrTagSystemTypes.ts
@@ -0,0 +1,58 @@
+/**
+ * New tag system based on:
+ * - #paywall: for paid publications
+ * - #sciencefiction or #research: for category
+ * - #author, #series, #publication, #quote: for type
+ * - #id_: for identifier
+ * - #payment (optional): for payment notes
+ *
+ * Everything is a Nostr note (kind 1)
+ * All tags are in English
+ */
+
+export type TagType = 'author' | 'series' | 'publication' | 'quote'
+export type TagCategory = 'sciencefiction' | 'research'
+
+export interface BaseTags {
+ type: TagType
+ category: TagCategory
+ id: string
+ paywall?: boolean
+ payment?: boolean
+}
+
+export interface AuthorTags extends BaseTags {
+ type: 'author'
+ title: string
+ preview?: string
+ mainnetAddress?: string
+ totalSponsoring?: number
+ pictureUrl?: string
+}
+
+export interface SeriesTags extends BaseTags {
+ type: 'series'
+ title: string
+ description: string
+ preview?: string
+ coverUrl?: string
+}
+
+export interface PublicationTags extends BaseTags {
+ type: 'publication'
+ title: string
+ preview?: string
+ seriesId?: string
+ bannerUrl?: string
+ zapAmount?: number
+ invoice?: string
+ paymentHash?: string
+ encryptedKey?: string
+}
+
+export interface QuoteTags extends BaseTags {
+ type: 'quote'
+ articleId: string
+ reviewerPubkey?: string
+ title?: string
+}
diff --git a/lib/nostrZapVerification.ts b/lib/nostrZapVerification.ts
index 3bcc6bd..023b419 100644
--- a/lib/nostrZapVerification.ts
+++ b/lib/nostrZapVerification.ts
@@ -1,7 +1,6 @@
import type { Event } from 'nostr-tools'
import { SimplePool } from 'nostr-tools'
-
-const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
+import { getPrimaryRelaySync } from './config'
function createZapFilters(targetPubkey: string, targetEventId: string, userPubkey: string) {
return [
@@ -30,6 +29,25 @@ async function isValidZapReceipt(
/**
* Check if user has paid for an article by looking for zap receipts
*/
+function handleZapReceiptEvent(
+ event: Event,
+ targetEventId: string,
+ targetPubkey: string,
+ userPubkey: string,
+ amount: number,
+ finalize: (value: boolean) => void,
+ resolved: { current: boolean }
+): void {
+ if (resolved.current) {
+ return
+ }
+ void isValidZapReceipt(event, targetEventId, targetPubkey, userPubkey, amount).then((isValid) => {
+ if (isValid) {
+ finalize(true)
+ }
+ })
+}
+
export function checkZapReceipt(
pool: SimplePool,
targetPubkey: string,
@@ -43,7 +61,8 @@ export function checkZapReceipt(
return new Promise((resolve) => {
let resolved = false
- const sub = pool.sub([RELAY_URL], createZapFilters(targetPubkey, targetEventId, userPubkey))
+ const relayUrl = getPrimaryRelaySync()
+ const sub = pool.sub([relayUrl], createZapFilters(targetPubkey, targetEventId, userPubkey))
const finalize = (value: boolean) => {
if (resolved) {
@@ -54,15 +73,9 @@ export function checkZapReceipt(
resolve(value)
}
+ const resolvedRef = { current: resolved }
sub.on('event', (event: Event) => {
- if (resolved) {
- return
- }
- void isValidZapReceipt(event, targetEventId, targetPubkey, userPubkey, amount).then((isValid) => {
- if (isValid) {
- finalize(true)
- }
- })
+ handleZapReceiptEvent(event, targetEventId, targetPubkey, userPubkey, amount, finalize, resolvedRef)
})
const end = () => finalize(false)
diff --git a/lib/notifications.ts b/lib/notifications.ts
index 685f69d..f9b4dc3 100644
--- a/lib/notifications.ts
+++ b/lib/notifications.ts
@@ -3,8 +3,7 @@ import { nostrService } from './nostr'
import { zapVerificationService } from './zapVerification'
import type { Notification } from '@/types/notifications'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
-
-const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
+import { getPrimaryRelaySync } from './config'
function createZapReceiptFilters(userPubkey: string) {
return [
@@ -86,7 +85,8 @@ export class NotificationService {
const filters = createZapReceiptFilters(userPubkey)
const poolWithSub = pool as SimplePoolWithSub
- const sub = poolWithSub.sub([RELAY_URL], filters)
+ const relayUrl = getPrimaryRelaySync()
+ const sub = poolWithSub.sub([relayUrl], filters)
registerZapSubscription(sub, userPubkey, onNotification)
diff --git a/lib/payment.ts b/lib/payment.ts
index b20b931..e4b7742 100644
--- a/lib/payment.ts
+++ b/lib/payment.ts
@@ -25,34 +25,42 @@ export class PaymentService {
* Create a Lightning invoice for an article payment
* First checks if author has created an invoice in the event tags, otherwise creates a new one
*/
+ private validateArticleAmount(zapAmount: number): { valid: boolean; error?: string } {
+ const expectedAmount = PLATFORM_COMMISSIONS.article.total
+ if (zapAmount !== expectedAmount) {
+ return {
+ valid: false,
+ error: `Invalid article payment amount: ${zapAmount} sats. Expected ${expectedAmount} sats (700 to author, 100 commission)`,
+ }
+ }
+ return { valid: true }
+ }
+
+ private validateInvoiceAmount(invoice: AlbyInvoice): { valid: boolean; error?: string } {
+ const split = calculateArticleSplit()
+ if (invoice.amount !== split.total) {
+ return {
+ valid: false,
+ error: `Invoice amount mismatch: ${invoice.amount} sats. Expected ${split.total} sats (${split.author} to author, ${split.platform} commission)`,
+ }
+ }
+ return { valid: true }
+ }
+
async createArticlePayment(request: PaymentRequest): Promise {
try {
- // Verify article amount matches expected commission structure
- const expectedAmount = PLATFORM_COMMISSIONS.article.total
- if (request.article.zapAmount !== expectedAmount) {
- return {
- success: false,
- error: `Invalid article payment amount: ${request.article.zapAmount} sats. Expected ${expectedAmount} sats (700 to author, 100 commission)`,
- }
+ const amountValidation = this.validateArticleAmount(request.article.zapAmount)
+ if (!amountValidation.valid) {
+ return { success: false, error: amountValidation.error ?? 'Invalid amount' }
}
const invoice = await resolveArticleInvoice(request.article)
-
- // Verify invoice amount matches expected commission structure
- const split = calculateArticleSplit()
- if (invoice.amount !== split.total) {
- return {
- success: false,
- error: `Invoice amount mismatch: ${invoice.amount} sats. Expected ${split.total} sats (${split.author} to author, ${split.platform} commission)`,
- }
+ const invoiceValidation = this.validateInvoiceAmount(invoice)
+ if (!invoiceValidation.valid) {
+ return { success: false, error: invoiceValidation.error ?? 'Invalid invoice amount' }
}
- // Create zap request event on Nostr
- await nostrService.createZapRequest(
- request.article.pubkey,
- request.article.id,
- request.article.zapAmount
- )
+ await nostrService.createZapRequest(request.article.pubkey, request.article.id, request.article.zapAmount)
return {
success: true,
diff --git a/lib/paymentPolling.ts b/lib/paymentPolling.ts
index d44ec01..c2d2288 100644
--- a/lib/paymentPolling.ts
+++ b/lib/paymentPolling.ts
@@ -1,265 +1 @@
-import { nostrService } from './nostr'
-import { articlePublisher } from './articlePublisher'
-import { getStoredPrivateContent } from './articleStorage'
-import { platformTracking } from './platformTracking'
-import { calculateArticleSplit } from './platformCommissions'
-import { automaticTransferService } from './automaticTransfer'
-import { lightningAddressService } from './lightningAddress'
-
-const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
-
-/**
- * Poll for payment completion via zap receipt verification
- * After payment is confirmed, sends private content to the user
- */
-async function pollPaymentUntilDeadline(
- articleId: string,
- articlePubkey: string,
- amount: number,
- recipientPubkey: string,
- interval: number,
- deadline: number
-): Promise {
- try {
- const zapReceiptExists = await nostrService.checkZapReceipt(articlePubkey, articleId, amount, recipientPubkey)
- if (zapReceiptExists) {
- const zapReceiptId = await getZapReceiptId(articlePubkey, articleId, amount, recipientPubkey)
- await sendPrivateContentAfterPayment(articleId, recipientPubkey, amount, zapReceiptId)
- return true
- }
- } catch (error) {
- console.error('Error checking zap receipt:', error)
- }
-
- if (Date.now() > deadline) {
- return false
- }
-
- return new Promise((resolve) => {
- setTimeout(() => {
- void pollPaymentUntilDeadline(articleId, articlePubkey, amount, recipientPubkey, interval, deadline)
- .then(resolve)
- .catch(() => resolve(false))
- }, interval)
- })
-}
-
-export async function waitForArticlePayment(
- _paymentHash: string,
- articleId: string,
- articlePubkey: string,
- amount: number,
- recipientPubkey: string,
- timeout: number = 300000 // 5 minutes
-): Promise {
- const interval = 2000
- const deadline = Date.now() + timeout
- try {
- return await pollPaymentUntilDeadline(articleId, articlePubkey, amount, recipientPubkey, interval, deadline)
- } catch (error) {
- console.error('Wait for payment error:', error)
- return false
- }
-}
-
-async function getZapReceiptId(
- articlePubkey: string,
- articleId: string,
- amount: number,
- recipientPubkey: string
-): Promise {
- try {
- const pool = nostrService.getPool()
- if (!pool) {
- return undefined
- }
-
- const filters = [
- {
- kinds: [9735],
- '#p': [articlePubkey],
- '#e': [articleId],
- limit: 1,
- },
- ]
-
- return new Promise((resolve) => {
- let resolved = false
- const poolWithSub = pool as import('@/types/nostr-tools-extended').SimplePoolWithSub
- const sub = poolWithSub.sub([RELAY_URL], filters)
-
- const finalize = (value: string | undefined) => {
- if (resolved) {
- return
- }
- resolved = true
- sub.unsub()
- resolve(value)
- }
-
- sub.on('event', (event) => {
- const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
- const amountInSats = amountTag ? Math.floor(parseInt(amountTag, 10) / 1000) : 0
- if (amountInSats === amount && event.pubkey === recipientPubkey) {
- finalize(event.id)
- }
- })
-
- sub.on('eose', () => finalize(undefined))
- setTimeout(() => finalize(undefined), 3000)
- })
- } catch (error) {
- console.error('Error getting zap receipt ID', {
- articleId,
- recipientPubkey,
- error: error instanceof Error ? error.message : 'Unknown error',
- })
- return undefined
- }
-}
-
-/**
- * Send private content to user after payment confirmation
- * Returns true if content was successfully sent and verified
- */
-async function sendPrivateContentAfterPayment(
- articleId: string,
- recipientPubkey: string,
- amount: number,
- zapReceiptId?: string
-): Promise {
- const storedContent = await getStoredPrivateContent(articleId)
-
- if (!storedContent) {
- console.error('Stored private content not found for article', {
- articleId,
- recipientPubkey,
- timestamp: new Date().toISOString(),
- })
- return false
- }
-
- const authorPrivateKey = nostrService.getPrivateKey()
-
- if (!authorPrivateKey) {
- console.error('Author private key not available, cannot send private content automatically', {
- articleId,
- recipientPubkey,
- authorPubkey: storedContent.authorPubkey,
- timestamp: new Date().toISOString(),
- })
- return false
- }
-
- const result = await articlePublisher.sendPrivateContent(articleId, recipientPubkey, authorPrivateKey)
-
- if (result.success && result.messageEventId) {
- const timestamp = Math.floor(Date.now() / 1000)
-
- // Verify payment amount matches expected commission structure
- const expectedSplit = calculateArticleSplit()
- if (amount !== expectedSplit.total) {
- console.error('Payment amount does not match expected commission structure', {
- articleId,
- paidAmount: amount,
- expectedTotal: expectedSplit.total,
- expectedAuthor: expectedSplit.author,
- expectedPlatform: expectedSplit.platform,
- timestamp: new Date().toISOString(),
- })
- }
-
- // Track content delivery with commission information
- const trackingData: import('./platformTracking').ContentDeliveryTracking = {
- articleId,
- articlePubkey: storedContent.authorPubkey,
- recipientPubkey,
- messageEventId: result.messageEventId,
- amount,
- authorAmount: expectedSplit.author,
- platformCommission: expectedSplit.platform,
- timestamp,
- verified: result.verified ?? false,
- }
-
- if (zapReceiptId) {
- trackingData.zapReceiptId = zapReceiptId
- }
-
- await platformTracking.trackContentDelivery(trackingData, authorPrivateKey)
-
- // Log commission information for platform tracking
- console.log('Article payment processed with commission', {
- articleId,
- totalAmount: amount,
- authorPortion: expectedSplit.author,
- platformCommission: expectedSplit.platform,
- recipientPubkey,
- timestamp: new Date().toISOString(),
- })
-
- // Trigger automatic transfer of author portion
- try {
- // Get author's Lightning address from profile
- const authorLightningAddress = await lightningAddressService.getLightningAddress(storedContent.authorPubkey)
-
- if (authorLightningAddress) {
- const transferResult = await automaticTransferService.transferAuthorPortion(
- authorLightningAddress,
- articleId,
- storedContent.authorPubkey,
- amount
- )
-
- if (!transferResult.success) {
- console.warn('Automatic transfer failed, will be retried later', {
- articleId,
- authorPubkey: storedContent.authorPubkey,
- error: transferResult.error,
- timestamp: new Date().toISOString(),
- })
- }
- } else {
- console.warn('Author Lightning address not available for automatic transfer', {
- articleId,
- authorPubkey: storedContent.authorPubkey,
- timestamp: new Date().toISOString(),
- })
- // Transfer will need to be done manually later
- }
- } catch (error) {
- console.error('Error triggering automatic transfer', {
- articleId,
- error: error instanceof Error ? error.message : 'Unknown error',
- timestamp: new Date().toISOString(),
- })
- // Don't fail the payment process if transfer fails
- }
-
- if (result.verified) {
- console.log('Private content sent and verified on relay', {
- articleId,
- recipientPubkey,
- messageEventId: result.messageEventId,
- timestamp: new Date().toISOString(),
- })
- return true
- } else {
- console.warn('Private content sent but not yet verified on relay', {
- articleId,
- recipientPubkey,
- messageEventId: result.messageEventId,
- timestamp: new Date().toISOString(),
- })
- return true
- }
- } else {
- console.error('Failed to send private content, but payment was confirmed', {
- articleId,
- recipientPubkey,
- error: result.error,
- timestamp: new Date().toISOString(),
- })
- return false
- }
-}
+export { waitForArticlePayment } from './paymentPollingCore'
diff --git a/lib/paymentPollingCore.ts b/lib/paymentPollingCore.ts
new file mode 100644
index 0000000..eee8830
--- /dev/null
+++ b/lib/paymentPollingCore.ts
@@ -0,0 +1,57 @@
+import { nostrService } from './nostr'
+import { getZapReceiptId } from './paymentPollingZapReceipt'
+import { sendPrivateContentAfterPayment } from './paymentPollingMain'
+
+/**
+ * Poll for payment completion via zap receipt verification
+ * After payment is confirmed, sends private content to the user
+ */
+async function pollPaymentUntilDeadline(
+ articleId: string,
+ articlePubkey: string,
+ amount: number,
+ recipientPubkey: string,
+ interval: number,
+ deadline: number
+): Promise {
+ try {
+ const zapReceiptExists = await nostrService.checkZapReceipt(articlePubkey, articleId, amount, recipientPubkey)
+ if (zapReceiptExists) {
+ const zapReceiptId = await getZapReceiptId(articlePubkey, articleId, amount, recipientPubkey)
+ await sendPrivateContentAfterPayment(articleId, recipientPubkey, amount, zapReceiptId)
+ return true
+ }
+ } catch (error) {
+ console.error('Error checking zap receipt:', error)
+ }
+
+ if (Date.now() > deadline) {
+ return false
+ }
+
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ void pollPaymentUntilDeadline(articleId, articlePubkey, amount, recipientPubkey, interval, deadline)
+ .then(resolve)
+ .catch(() => resolve(false))
+ }, interval)
+ })
+}
+
+export async function waitForArticlePayment(
+ _paymentHash: string,
+ articleId: string,
+ articlePubkey: string,
+ amount: number,
+ recipientPubkey: string,
+ timeout: number = 300000 // 5 minutes
+): Promise {
+ const interval = 2000
+ const deadline = Date.now() + timeout
+ try {
+ return await pollPaymentUntilDeadline(articleId, articlePubkey, amount, recipientPubkey, interval, deadline)
+ } catch (error) {
+ console.error('Wait for payment error:', error)
+ return false
+ }
+}
diff --git a/lib/paymentPollingMain.ts b/lib/paymentPollingMain.ts
new file mode 100644
index 0000000..2f75b43
--- /dev/null
+++ b/lib/paymentPollingMain.ts
@@ -0,0 +1,48 @@
+import { getStoredPrivateContent } from './articleStorage'
+import { nostrService } from './nostr'
+import { articlePublisher } from './articlePublisher'
+import { validatePaymentData, verifyPaymentAmount } from './paymentPollingValidation'
+import { createTrackingData, triggerAutomaticTransfer, logPaymentResult } from './paymentPollingTracking'
+import { platformTracking } from './platformTracking'
+
+/**
+ * Send private content to user after payment confirmation
+ * Returns true if content was successfully sent and verified
+ */
+export async function sendPrivateContentAfterPayment(
+ articleId: string,
+ recipientPubkey: string,
+ amount: number,
+ zapReceiptId?: string
+): Promise {
+ const storedContent = await getStoredPrivateContent(articleId)
+ const authorPrivateKey = nostrService.getPrivateKey()
+
+ const validation = validatePaymentData(storedContent, authorPrivateKey, articleId, recipientPubkey)
+ if (!validation.valid || !validation.storedContent || !validation.authorPrivateKey) {
+ return false
+ }
+
+ const result = await articlePublisher.sendPrivateContent(articleId, recipientPubkey, validation.authorPrivateKey)
+
+ if (result.success && result.messageEventId) {
+ verifyPaymentAmount(amount, articleId)
+
+ const trackingData = createTrackingData(
+ articleId,
+ validation.storedContent.authorPubkey,
+ recipientPubkey,
+ result.messageEventId,
+ amount,
+ result.verified ?? false,
+ zapReceiptId
+ )
+
+ await platformTracking.trackContentDelivery(trackingData, validation.authorPrivateKey)
+ await triggerAutomaticTransfer(validation.storedContent.authorPubkey, articleId, amount)
+
+ return logPaymentResult(result, articleId, recipientPubkey, amount)
+ }
+
+ return logPaymentResult(result, articleId, recipientPubkey, amount)
+}
diff --git a/lib/paymentPollingTracking.ts b/lib/paymentPollingTracking.ts
new file mode 100644
index 0000000..bd77742
--- /dev/null
+++ b/lib/paymentPollingTracking.ts
@@ -0,0 +1,128 @@
+import { calculateArticleSplit } from './platformCommissions'
+import { lightningAddressService } from './lightningAddress'
+import { automaticTransferService } from './automaticTransfer'
+
+export function createTrackingData(
+ articleId: string,
+ authorPubkey: string,
+ recipientPubkey: string,
+ messageEventId: string,
+ amount: number,
+ verified: boolean,
+ zapReceiptId?: string
+): import('./platformTracking').ContentDeliveryTracking {
+ const expectedSplit = calculateArticleSplit()
+ const timestamp = Math.floor(Date.now() / 1000)
+
+ const trackingData: import('./platformTracking').ContentDeliveryTracking = {
+ articleId,
+ articlePubkey: authorPubkey,
+ recipientPubkey,
+ messageEventId,
+ amount,
+ authorAmount: expectedSplit.author,
+ platformCommission: expectedSplit.platform,
+ timestamp,
+ verified,
+ }
+
+ if (zapReceiptId) {
+ trackingData.zapReceiptId = zapReceiptId
+ }
+
+ return trackingData
+}
+
+export async function triggerAutomaticTransfer(
+ authorPubkey: string,
+ articleId: string,
+ amount: number
+): Promise {
+ try {
+ const authorLightningAddress = await lightningAddressService.getLightningAddress(authorPubkey)
+
+ if (authorLightningAddress) {
+ const transferResult = await automaticTransferService.transferAuthorPortion(
+ authorLightningAddress,
+ articleId,
+ authorPubkey,
+ amount
+ )
+
+ if (!transferResult.success) {
+ console.warn('Automatic transfer failed, will be retried later', {
+ articleId,
+ authorPubkey,
+ error: transferResult.error,
+ timestamp: new Date().toISOString(),
+ })
+ }
+ } else {
+ console.warn('Author Lightning address not available for automatic transfer', {
+ articleId,
+ authorPubkey,
+ timestamp: new Date().toISOString(),
+ })
+ }
+ } catch (error) {
+ console.error('Error triggering automatic transfer', {
+ articleId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ timestamp: new Date().toISOString(),
+ })
+ }
+}
+
+export function logPaymentSuccess(
+ articleId: string,
+ recipientPubkey: string,
+ amount: number,
+ messageEventId: string,
+ verified: boolean
+): void {
+ const expectedSplit = calculateArticleSplit()
+ console.log('Article payment processed with commission', {
+ articleId,
+ totalAmount: amount,
+ authorPortion: expectedSplit.author,
+ platformCommission: expectedSplit.platform,
+ recipientPubkey,
+ timestamp: new Date().toISOString(),
+ })
+
+ if (verified) {
+ console.log('Private content sent and verified on relay', {
+ articleId,
+ recipientPubkey,
+ messageEventId,
+ timestamp: new Date().toISOString(),
+ })
+ } else {
+ console.warn('Private content sent but not yet verified on relay', {
+ articleId,
+ recipientPubkey,
+ messageEventId,
+ timestamp: new Date().toISOString(),
+ })
+ }
+}
+
+export function logPaymentResult(
+ result: { success: boolean; messageEventId?: string; verified?: boolean; error?: string },
+ articleId: string,
+ recipientPubkey: string,
+ amount: number
+): boolean {
+ if (result.success && result.messageEventId) {
+ logPaymentSuccess(articleId, recipientPubkey, amount, result.messageEventId, result.verified ?? false)
+ return true
+ } else {
+ console.error('Failed to send private content, but payment was confirmed', {
+ articleId,
+ recipientPubkey,
+ error: result.error,
+ timestamp: new Date().toISOString(),
+ })
+ return false
+ }
+}
diff --git a/lib/paymentPollingValidation.ts b/lib/paymentPollingValidation.ts
new file mode 100644
index 0000000..0a4ce5f
--- /dev/null
+++ b/lib/paymentPollingValidation.ts
@@ -0,0 +1,43 @@
+import { calculateArticleSplit } from './platformCommissions'
+
+export function validatePaymentData(
+ storedContent: { authorPubkey: string } | null,
+ authorPrivateKey: string | null,
+ articleId: string,
+ recipientPubkey: string
+): { valid: boolean; storedContent?: { authorPubkey: string }; authorPrivateKey?: string } {
+ if (!storedContent) {
+ console.error('Stored private content not found for article', {
+ articleId,
+ recipientPubkey,
+ timestamp: new Date().toISOString(),
+ })
+ return { valid: false }
+ }
+
+ if (!authorPrivateKey) {
+ console.error('Author private key not available, cannot send private content automatically', {
+ articleId,
+ recipientPubkey,
+ authorPubkey: storedContent.authorPubkey,
+ timestamp: new Date().toISOString(),
+ })
+ return { valid: false }
+ }
+
+ return { valid: true, storedContent, authorPrivateKey }
+}
+
+export function verifyPaymentAmount(amount: number, articleId: string): void {
+ const expectedSplit = calculateArticleSplit()
+ if (amount !== expectedSplit.total) {
+ console.error('Payment amount does not match expected commission structure', {
+ articleId,
+ paidAmount: amount,
+ expectedTotal: expectedSplit.total,
+ expectedAuthor: expectedSplit.author,
+ expectedPlatform: expectedSplit.platform,
+ timestamp: new Date().toISOString(),
+ })
+ }
+}
diff --git a/lib/paymentPollingZapReceipt.ts b/lib/paymentPollingZapReceipt.ts
new file mode 100644
index 0000000..9404fcc
--- /dev/null
+++ b/lib/paymentPollingZapReceipt.ts
@@ -0,0 +1,83 @@
+import { nostrService } from './nostr'
+import { getPrimaryRelaySync } from './config'
+
+export function parseZapAmount(event: import('nostr-tools').Event): number {
+ const amountTag = event.tags.find((tag) => tag[0] === 'amount')?.[1]
+ return amountTag ? Math.floor(parseInt(amountTag, 10) / 1000) : 0
+}
+
+export function createZapReceiptSubscription(poolWithSub: import('@/types/nostr-tools-extended').SimplePoolWithSub, articlePubkey: string, articleId: string) {
+ const filters = [
+ {
+ kinds: [9735],
+ '#p': [articlePubkey],
+ '#e': [articleId],
+ limit: 1,
+ },
+ ]
+ const relayUrl = getPrimaryRelaySync()
+ return poolWithSub.sub([relayUrl], filters)
+}
+
+export function handleZapReceiptEvent(
+ event: import('nostr-tools').Event,
+ amount: number,
+ recipientPubkey: string,
+ finalize: (value: string | undefined) => void
+): void {
+ const amountInSats = parseZapAmount(event)
+ if (amountInSats === amount && event.pubkey === recipientPubkey) {
+ finalize(event.id)
+ }
+}
+
+export function createZapReceiptPromise(
+ sub: import('@/types/nostr-tools-extended').SimplePoolWithSub['sub'] extends (...args: any[]) => infer R ? R : never,
+ amount: number,
+ recipientPubkey: string
+): Promise {
+ return new Promise((resolve) => {
+ let resolved = false
+
+ const finalize = (value: string | undefined) => {
+ if (resolved) {
+ return
+ }
+ resolved = true
+ sub.unsub()
+ resolve(value)
+ }
+
+ sub.on('event', (event) => {
+ handleZapReceiptEvent(event, amount, recipientPubkey, finalize)
+ })
+
+ sub.on('eose', () => finalize(undefined))
+ setTimeout(() => finalize(undefined), 3000)
+ })
+}
+
+export async function getZapReceiptId(
+ articlePubkey: string,
+ articleId: string,
+ amount: number,
+ recipientPubkey: string
+): Promise {
+ try {
+ const pool = nostrService.getPool()
+ if (!pool) {
+ return undefined
+ }
+
+ const poolWithSub = pool as import('@/types/nostr-tools-extended').SimplePoolWithSub
+ const sub = createZapReceiptSubscription(poolWithSub, articlePubkey, articleId)
+ return createZapReceiptPromise(sub, amount, recipientPubkey)
+ } catch (error) {
+ console.error('Error getting zap receipt ID', {
+ articleId,
+ recipientPubkey,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ })
+ return undefined
+ }
+}
diff --git a/lib/platformCommissions.ts b/lib/platformCommissions.ts
index be697c7..5ec3803 100644
--- a/lib/platformCommissions.ts
+++ b/lib/platformCommissions.ts
@@ -49,8 +49,10 @@ export const PLATFORM_COMMISSIONS = {
/**
* Platform Lightning address/node for receiving commissions
* This should be configured with the platform's Lightning node
+ *
+ * @deprecated Use getPlatformLightningAddress() or getPlatformLightningAddressSync() from './platformConfig' instead
*/
-export const PLATFORM_LIGHTNING_ADDRESS = process.env.NEXT_PUBLIC_PLATFORM_LIGHTNING_ADDRESS || ''
+export const PLATFORM_LIGHTNING_ADDRESS = ''
/**
* Calculate commission split for article payment
diff --git a/lib/platformConfig.ts b/lib/platformConfig.ts
index cf1d5d3..195447b 100644
--- a/lib/platformConfig.ts
+++ b/lib/platformConfig.ts
@@ -1,9 +1,29 @@
export const PLATFORM_NPUB = 'npub18s03s39fa80ce2n3cmm0zme3jqehc82h6ld9sxq03uejqm3d05gsae0fuu'
export const PLATFORM_BITCOIN_ADDRESS = 'bc1qerauk5yhqytl6z93ckvwkylup8s0256uenzg9y'
+import { getPlatformLightningAddress as getAddress, getPlatformLightningAddressSync as getAddressSync } from './config'
+
/**
* Platform Lightning address for receiving commissions
* This should be configured with the platform's Lightning node
* Format: user@domain.com or LNURL
+ *
+ * @deprecated Use getPlatformLightningAddress() or getPlatformLightningAddressSync() instead
*/
-export const PLATFORM_LIGHTNING_ADDRESS = process.env.NEXT_PUBLIC_PLATFORM_LIGHTNING_ADDRESS || ''
+export const PLATFORM_LIGHTNING_ADDRESS = ''
+
+/**
+ * Get platform Lightning address (async)
+ * Uses IndexedDB if available, otherwise returns default
+ */
+export async function getPlatformLightningAddress(): Promise {
+ return getAddress()
+}
+
+/**
+ * Get platform Lightning address (sync)
+ * Returns default if IndexedDB is not ready
+ */
+export function getPlatformLightningAddressSync(): string {
+ return getAddressSync()
+}
diff --git a/lib/platformTracking.ts b/lib/platformTracking.ts
index a95ac2a..268fba8 100644
--- a/lib/platformTracking.ts
+++ b/lib/platformTracking.ts
@@ -1,22 +1,12 @@
-import { Event, EventTemplate, getEventHash, signEvent } from 'nostr-tools'
+import { Event } from 'nostr-tools'
import { nostrService } from './nostr'
import { PLATFORM_NPUB } from './platformConfig'
import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
+import type { ContentDeliveryTracking } from './platformTrackingTypes'
+import { buildTrackingEvent } from './platformTrackingEvents'
+import { parseTrackingEvent, createArticleDeliveriesSubscription, createRecipientDeliveriesSubscription } from './platformTrackingQueries'
-const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
-
-export interface ContentDeliveryTracking {
- articleId: string
- articlePubkey: string
- recipientPubkey: string
- messageEventId: string
- zapReceiptId?: string
- amount: number
- authorAmount?: number
- platformCommission?: number
- timestamp: number
- verified: boolean
-}
+export type { ContentDeliveryTracking } from './platformTrackingTypes'
/**
* Platform tracking service
@@ -25,7 +15,34 @@ export interface ContentDeliveryTracking {
*/
export class PlatformTrackingService {
private readonly platformPubkey: string = PLATFORM_NPUB
- private readonly trackingKind = 30078 // Custom kind for platform tracking
+
+ private async publishTrackingEvent(event: Event): Promise {
+ const pool = nostrService.getPool()
+ if (!pool) {
+ throw new Error('Pool not initialized')
+ }
+ const poolWithSub = pool as SimplePoolWithSub
+ const { getPrimaryRelaySync } = await import('./config')
+ const relayUrl = getPrimaryRelaySync()
+ const pubs = poolWithSub.publish([relayUrl], event)
+ await Promise.all(pubs)
+ }
+
+ private validateTrackingPool(): { pool: SimplePoolWithSub; authorPubkey: string } | null {
+ const pool = nostrService.getPool()
+ if (!pool) {
+ console.error('Pool not initialized for platform tracking')
+ return null
+ }
+
+ const authorPubkey = nostrService.getPublicKey()
+ if (!authorPubkey) {
+ console.error('Author public key not available for tracking')
+ return null
+ }
+
+ return { pool: pool as SimplePoolWithSub, authorPubkey }
+ }
/**
* Publish a content delivery tracking event
@@ -37,62 +54,14 @@ export class PlatformTrackingService {
authorPrivateKey: string
): Promise {
try {
- const pool = nostrService.getPool()
- if (!pool) {
- console.error('Pool not initialized for platform tracking')
+ const validation = this.validateTrackingPool()
+ if (!validation) {
return null
}
- const authorPubkey = nostrService.getPublicKey()
- if (!authorPubkey) {
- console.error('Author public key not available for tracking')
- return null
- }
-
- const eventTemplate: EventTemplate = {
- kind: this.trackingKind,
- created_at: Math.floor(Date.now() / 1000),
- tags: [
- ['p', this.platformPubkey], // Tag platform for querying
- ['article', tracking.articleId],
- ['author', tracking.articlePubkey],
- ['recipient', tracking.recipientPubkey],
- ['message', tracking.messageEventId],
- ['amount', tracking.amount.toString()],
- ...(tracking.authorAmount ? [['author_amount', tracking.authorAmount.toString()]] : []),
- ...(tracking.platformCommission ? [['platform_commission', tracking.platformCommission.toString()]] : []),
- ['verified', tracking.verified ? 'true' : 'false'],
- ['timestamp', tracking.timestamp.toString()],
- ...(tracking.zapReceiptId ? [['zap_receipt', tracking.zapReceiptId]] : []),
- ],
- content: JSON.stringify({
- articleId: tracking.articleId,
- articlePubkey: tracking.articlePubkey,
- recipientPubkey: tracking.recipientPubkey,
- messageEventId: tracking.messageEventId,
- amount: tracking.amount,
- authorAmount: tracking.authorAmount,
- platformCommission: tracking.platformCommission,
- verified: tracking.verified,
- timestamp: tracking.timestamp,
- zapReceiptId: tracking.zapReceiptId,
- }),
- }
-
- const unsignedEvent = {
- pubkey: authorPubkey,
- ...eventTemplate,
- }
-
- const event: Event = {
- ...unsignedEvent,
- id: getEventHash(unsignedEvent),
- sig: signEvent(unsignedEvent, authorPrivateKey),
- } as Event
-
- const poolWithSub = pool as SimplePoolWithSub
- const pubs = poolWithSub.publish([RELAY_URL], event)
- await Promise.all(pubs)
+ const { authorPubkey } = validation
+ const event = buildTrackingEvent(tracking, authorPubkey, authorPrivateKey, this.platformPubkey)
+ await this.publishTrackingEvent(event)
console.log('Platform tracking event published', {
eventId: event.id,
@@ -127,20 +96,11 @@ export class PlatformTrackingService {
return []
}
- const filters = [
- {
- kinds: [this.trackingKind],
- '#p': [this.platformPubkey],
- '#article': [articleId],
- limit: 100,
- },
- ]
-
return new Promise((resolve) => {
const deliveries: ContentDeliveryTracking[] = []
let resolved = false
const poolWithSub = pool as SimplePoolWithSub
- const sub = poolWithSub.sub([RELAY_URL], filters)
+ const sub = createArticleDeliveriesSubscription(poolWithSub, articleId, this.platformPubkey)
const finalize = () => {
if (resolved) {
@@ -152,34 +112,9 @@ export class PlatformTrackingService {
}
sub.on('event', (event: Event) => {
- try {
- const data = JSON.parse(event.content) as ContentDeliveryTracking
- const zapReceiptTag = event.tags.find((tag) => tag[0] === 'zap_receipt')?.[1]
- const authorAmountTag = event.tags.find((tag) => tag[0] === 'author_amount')?.[1]
- const platformCommissionTag = event.tags.find((tag) => tag[0] === 'platform_commission')?.[1]
-
- const delivery: ContentDeliveryTracking = {
- ...data,
- }
-
- if (authorAmountTag) {
- delivery.authorAmount = parseInt(authorAmountTag, 10)
- }
-
- if (platformCommissionTag) {
- delivery.platformCommission = parseInt(platformCommissionTag, 10)
- }
-
- if (zapReceiptTag) {
- delivery.zapReceiptId = zapReceiptTag
- }
-
+ const delivery = parseTrackingEvent(event)
+ if (delivery) {
deliveries.push(delivery)
- } catch (error) {
- console.error('Error parsing tracking event', {
- eventId: event.id,
- error: error instanceof Error ? error.message : 'Unknown error',
- })
}
})
@@ -205,20 +140,11 @@ export class PlatformTrackingService {
return []
}
- const filters = [
- {
- kinds: [this.trackingKind],
- '#p': [this.platformPubkey],
- '#recipient': [recipientPubkey],
- limit: 100,
- },
- ]
-
return new Promise((resolve) => {
const deliveries: ContentDeliveryTracking[] = []
let resolved = false
const poolWithSub = pool as SimplePoolWithSub
- const sub = poolWithSub.sub([RELAY_URL], filters)
+ const sub = createRecipientDeliveriesSubscription(poolWithSub, recipientPubkey, this.platformPubkey)
const finalize = () => {
if (resolved) {
@@ -230,24 +156,9 @@ export class PlatformTrackingService {
}
sub.on('event', (event: Event) => {
- try {
- const data = JSON.parse(event.content) as ContentDeliveryTracking
- const zapReceiptTag = event.tags.find((tag) => tag[0] === 'zap_receipt')?.[1]
-
- const delivery: ContentDeliveryTracking = {
- ...data,
- }
-
- if (zapReceiptTag) {
- delivery.zapReceiptId = zapReceiptTag
- }
-
+ const delivery = parseTrackingEvent(event)
+ if (delivery) {
deliveries.push(delivery)
- } catch (error) {
- console.error('Error parsing tracking event', {
- eventId: event.id,
- error: error instanceof Error ? error.message : 'Unknown error',
- })
}
})
diff --git a/lib/platformTrackingEvents.ts b/lib/platformTrackingEvents.ts
new file mode 100644
index 0000000..273e42f
--- /dev/null
+++ b/lib/platformTrackingEvents.ts
@@ -0,0 +1,60 @@
+import { Event, EventTemplate, getEventHash, signEvent } from 'nostr-tools'
+import type { ContentDeliveryTracking } from './platformTrackingTypes'
+
+const TRACKING_KIND = 30078 // Custom kind for platform tracking
+
+export function buildTrackingTags(tracking: ContentDeliveryTracking, platformPubkey: string): string[][] {
+ return [
+ ['p', platformPubkey],
+ ['article', tracking.articleId],
+ ['author', tracking.articlePubkey],
+ ['recipient', tracking.recipientPubkey],
+ ['message', tracking.messageEventId],
+ ['amount', tracking.amount.toString()],
+ ...(tracking.authorAmount ? [['author_amount', tracking.authorAmount.toString()]] : []),
+ ...(tracking.platformCommission ? [['platform_commission', tracking.platformCommission.toString()]] : []),
+ ['verified', tracking.verified ? 'true' : 'false'],
+ ['timestamp', tracking.timestamp.toString()],
+ ...(tracking.zapReceiptId ? [['zap_receipt', tracking.zapReceiptId]] : []),
+ ]
+}
+
+export function buildTrackingEvent(
+ tracking: ContentDeliveryTracking,
+ authorPubkey: string,
+ authorPrivateKey: string,
+ platformPubkey: string
+): Event {
+ const eventTemplate: EventTemplate = {
+ kind: TRACKING_KIND,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: buildTrackingTags(tracking, platformPubkey),
+ content: JSON.stringify({
+ articleId: tracking.articleId,
+ articlePubkey: tracking.articlePubkey,
+ recipientPubkey: tracking.recipientPubkey,
+ messageEventId: tracking.messageEventId,
+ amount: tracking.amount,
+ authorAmount: tracking.authorAmount,
+ platformCommission: tracking.platformCommission,
+ verified: tracking.verified,
+ timestamp: tracking.timestamp,
+ zapReceiptId: tracking.zapReceiptId,
+ }),
+ }
+
+ const unsignedEvent = {
+ pubkey: authorPubkey,
+ ...eventTemplate,
+ }
+
+ return {
+ ...unsignedEvent,
+ id: getEventHash(unsignedEvent),
+ sig: signEvent(unsignedEvent, authorPrivateKey),
+ } as Event
+}
+
+export function getTrackingKind(): number {
+ return TRACKING_KIND
+}
diff --git a/lib/platformTrackingQueries.ts b/lib/platformTrackingQueries.ts
new file mode 100644
index 0000000..7b3d082
--- /dev/null
+++ b/lib/platformTrackingQueries.ts
@@ -0,0 +1,64 @@
+import { Event } from 'nostr-tools'
+import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
+import { getPrimaryRelaySync } from './config'
+import type { ContentDeliveryTracking } from './platformTrackingTypes'
+import { getTrackingKind } from './platformTrackingEvents'
+
+export function parseTrackingEvent(event: Event): ContentDeliveryTracking | null {
+ try {
+ const data = JSON.parse(event.content) as ContentDeliveryTracking
+ const zapReceiptTag = event.tags.find((tag) => tag[0] === 'zap_receipt')?.[1]
+ const authorAmountTag = event.tags.find((tag) => tag[0] === 'author_amount')?.[1]
+ const platformCommissionTag = event.tags.find((tag) => tag[0] === 'platform_commission')?.[1]
+
+ const delivery: ContentDeliveryTracking = {
+ ...data,
+ }
+
+ if (authorAmountTag) {
+ delivery.authorAmount = parseInt(authorAmountTag, 10)
+ }
+
+ if (platformCommissionTag) {
+ delivery.platformCommission = parseInt(platformCommissionTag, 10)
+ }
+
+ if (zapReceiptTag) {
+ delivery.zapReceiptId = zapReceiptTag
+ }
+
+ return delivery
+ } catch (error) {
+ console.error('Error parsing tracking event', {
+ eventId: event.id,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ })
+ return null
+ }
+}
+
+export function createArticleDeliveriesSubscription(pool: SimplePoolWithSub, articleId: string, platformPubkey: string) {
+ const filters = [
+ {
+ kinds: [getTrackingKind()],
+ '#p': [platformPubkey],
+ '#article': [articleId],
+ limit: 100,
+ },
+ ]
+ const relayUrl = getPrimaryRelaySync()
+ return pool.sub([relayUrl], filters)
+}
+
+export function createRecipientDeliveriesSubscription(pool: SimplePoolWithSub, recipientPubkey: string, platformPubkey: string) {
+ const filters = [
+ {
+ kinds: [getTrackingKind()],
+ '#p': [platformPubkey],
+ '#recipient': [recipientPubkey],
+ limit: 100,
+ },
+ ]
+ const relayUrl = getPrimaryRelaySync()
+ return pool.sub([relayUrl], filters)
+}
diff --git a/lib/platformTrackingTypes.ts b/lib/platformTrackingTypes.ts
new file mode 100644
index 0000000..9c9c1e8
--- /dev/null
+++ b/lib/platformTrackingTypes.ts
@@ -0,0 +1,12 @@
+export interface ContentDeliveryTracking {
+ articleId: string
+ articlePubkey: string
+ recipientPubkey: string
+ messageEventId: string
+ zapReceiptId?: string
+ amount: number
+ authorAmount?: number
+ platformCommission?: number
+ timestamp: number
+ verified: boolean
+}
diff --git a/lib/reviewReward.ts b/lib/reviewReward.ts
index f4c0bde..65213e4 100644
--- a/lib/reviewReward.ts
+++ b/lib/reviewReward.ts
@@ -1,10 +1,9 @@
-import { getAlbyService } from './alby'
-import { calculateReviewSplit, PLATFORM_COMMISSIONS } from './platformCommissions'
-import { automaticTransferService } from './automaticTransfer'
-import { nostrService } from './nostr'
-import { lightningAddressService } from './lightningAddress'
-import type { AlbyInvoice } from '@/types/alby'
-import type { Event } from 'nostr-tools'
+import { calculateReviewSplit } from './platformCommissions'
+import type { ReviewRewardRequest, ReviewRewardResult } from './reviewRewardTypes'
+import { createReviewInvoice } from './reviewRewardInvoice'
+import { transferReviewerPortionIfAvailable } from './reviewRewardTransfer'
+import { trackReviewReward } from './reviewRewardTracking'
+import { updateReviewWithReward } from './reviewRewardUpdate'
/**
* Review reward service
@@ -14,46 +13,13 @@ import type { Event } from 'nostr-tools'
* - Reviewer: 49 sats
* - Platform: 21 sats
*/
-export interface ReviewRewardRequest {
- reviewId: string
- articleId: string
- reviewerPubkey: string
- reviewerLightningAddress?: string
- authorPubkey: string
- authorPrivateKey: string
-}
-
-export interface ReviewRewardResult {
- success: boolean
- invoice?: AlbyInvoice
- paymentHash?: string
- error?: string
- split: {
- reviewer: number
- platform: number
- total: number
- }
-}
+export type { ReviewRewardRequest, ReviewRewardResult } from './reviewRewardTypes'
export class ReviewRewardService {
- /**
- * Create review reward payment with commission split
- */
async createReviewRewardPayment(request: ReviewRewardRequest): Promise {
try {
const split = calculateReviewSplit()
-
- // Verify author has permission to reward this review
- // (should be verified before calling this function)
-
- const alby = getAlbyService()
- await alby.enable()
-
- const invoice = await alby.createInvoice({
- amount: split.total,
- description: `Review reward: ${request.reviewId} (${split.reviewer} sats to reviewer, ${split.platform} sats commission)`,
- expiry: 3600, // 1 hour
- })
+ const invoice = await createReviewInvoice(split, request)
console.log('Review reward invoice created', {
reviewId: request.reviewId,
@@ -97,44 +63,9 @@ export class ReviewRewardService {
): Promise {
try {
const split = calculateReviewSplit()
-
- // Get reviewer Lightning address if not provided
- let reviewerLightningAddress: string | undefined = request.reviewerLightningAddress
- if (!reviewerLightningAddress) {
- const address = await lightningAddressService.getLightningAddress(request.reviewerPubkey)
- reviewerLightningAddress = address ?? undefined
- }
-
- // Transfer reviewer portion
- if (reviewerLightningAddress) {
- const transferResult = await automaticTransferService.transferReviewerPortion(
- reviewerLightningAddress,
- request.reviewId,
- request.reviewerPubkey,
- split.total
- )
-
- if (!transferResult.success) {
- console.error('Failed to transfer reviewer portion', {
- reviewId: request.reviewId,
- error: transferResult.error,
- timestamp: new Date().toISOString(),
- })
- // Continue anyway - transfer can be done manually later
- }
- } else {
- console.warn('Reviewer Lightning address not available for automatic transfer', {
- reviewId: request.reviewId,
- reviewerPubkey: request.reviewerPubkey,
- timestamp: new Date().toISOString(),
- })
- }
-
- // Track the reward payment
- await this.trackReviewReward(request, split, paymentHash)
-
- // Update review event with reward tag
- await this.updateReviewWithReward(request.reviewId, request.authorPrivateKey)
+ await transferReviewerPortionIfAvailable(request, split)
+ await trackReviewReward(request, split, paymentHash)
+ await updateReviewWithReward(request.reviewId, request.authorPrivateKey)
console.log('Review reward processed', {
reviewId: request.reviewId,
@@ -156,134 +87,6 @@ export class ReviewRewardService {
}
}
- /**
- * Track review reward payment
- */
- private async trackReviewReward(
- request: ReviewRewardRequest,
- split: { reviewer: number; platform: number; total: number },
- paymentHash: string
- ): Promise {
- try {
- // In production, publish tracking event on Nostr similar to article payments
- console.log('Review reward tracked', {
- reviewId: request.reviewId,
- articleId: request.articleId,
- reviewerPubkey: request.reviewerPubkey,
- authorPubkey: request.authorPubkey,
- reviewerAmount: split.reviewer,
- platformCommission: split.platform,
- paymentHash,
- timestamp: new Date().toISOString(),
- })
- } catch (error) {
- console.error('Error tracking review reward', {
- reviewId: request.reviewId,
- error: error instanceof Error ? error.message : 'Unknown error',
- timestamp: new Date().toISOString(),
- })
- }
- }
-
- /**
- * Update review event with reward tag
- * Publishes a new event that references the original review with reward tags
- */
- private async updateReviewWithReward(reviewId: string, authorPrivateKey: string): Promise {
- try {
- const pool = nostrService.getPool()
- if (!pool) {
- throw new Error('Pool not initialized')
- }
-
- // Get the original event from pool
- const poolWithSub = pool as import('@/types/nostr-tools-extended').SimplePoolWithSub
- const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
- const filters = [
- {
- kinds: [1],
- ids: [reviewId],
- limit: 1,
- },
- ]
-
- const originalEvent = await new Promise((resolve) => {
- let resolved = false
- const sub = poolWithSub.sub([RELAY_URL], filters)
-
- const finalize = (value: Event | null) => {
- if (resolved) {
- return
- }
- resolved = true
- sub.unsub()
- resolve(value)
- }
-
- sub.on('event', (event: Event) => {
- finalize(event)
- })
-
- sub.on('eose', () => finalize(null))
- setTimeout(() => finalize(null), 5000)
- })
-
- if (!originalEvent) {
- console.error('Original review event not found', {
- reviewId,
- timestamp: new Date().toISOString(),
- })
- return
- }
-
- // Check if already rewarded
- const alreadyRewarded = originalEvent.tags.some((tag) => tag[0] === 'rewarded' && tag[1] === 'true')
- if (alreadyRewarded) {
- console.log('Review already marked as rewarded', {
- reviewId,
- timestamp: new Date().toISOString(),
- })
- return
- }
-
- // Create updated event with reward tags
- nostrService.setPrivateKey(authorPrivateKey)
- nostrService.setPublicKey(originalEvent.pubkey)
-
- const updatedEvent = {
- kind: 1,
- created_at: Math.floor(Date.now() / 1000),
- tags: [
- ...originalEvent.tags.filter((tag) => tag[0] !== 'rewarded' && tag[0] !== 'reward_amount'),
- ['e', reviewId], // Reference to original review
- ['rewarded', 'true'],
- ['reward_amount', PLATFORM_COMMISSIONS.review.total.toString()],
- ],
- content: originalEvent.content, // Keep original content
- }
-
- const publishedEvent = await nostrService.publishEvent(updatedEvent)
-
- if (publishedEvent) {
- console.log('Review updated with reward tag', {
- reviewId,
- updatedEventId: publishedEvent.id,
- timestamp: new Date().toISOString(),
- })
- } else {
- console.error('Failed to publish updated review event', {
- reviewId,
- timestamp: new Date().toISOString(),
- })
- }
- } catch (error) {
- console.error('Error updating review with reward', {
- reviewId,
- error: error instanceof Error ? error.message : 'Unknown error',
- timestamp: new Date().toISOString(),
- })
- }
- }
}
export const reviewRewardService = new ReviewRewardService()
diff --git a/lib/reviewRewardInvoice.ts b/lib/reviewRewardInvoice.ts
new file mode 100644
index 0000000..f98de97
--- /dev/null
+++ b/lib/reviewRewardInvoice.ts
@@ -0,0 +1,13 @@
+import { getAlbyService } from './alby'
+import type { ReviewRewardRequest } from './reviewRewardTypes'
+
+export async function createReviewInvoice(split: { total: number; reviewer: number; platform: number }, request: ReviewRewardRequest) {
+ const alby = getAlbyService()
+ await alby.enable()
+
+ return await alby.createInvoice({
+ amount: split.total,
+ description: `Review reward: ${request.reviewId} (${split.reviewer} sats to reviewer, ${split.platform} sats commission)`,
+ expiry: 3600, // 1 hour
+ })
+}
diff --git a/lib/reviewRewardTracking.ts b/lib/reviewRewardTracking.ts
new file mode 100644
index 0000000..5e24e77
--- /dev/null
+++ b/lib/reviewRewardTracking.ts
@@ -0,0 +1,27 @@
+import type { ReviewRewardRequest } from './reviewRewardTypes'
+
+export async function trackReviewReward(
+ request: ReviewRewardRequest,
+ split: { reviewer: number; platform: number; total: number },
+ paymentHash: string
+): Promise {
+ try {
+ // In production, publish tracking event on Nostr similar to article payments
+ console.log('Review reward tracked', {
+ reviewId: request.reviewId,
+ articleId: request.articleId,
+ reviewerPubkey: request.reviewerPubkey,
+ authorPubkey: request.authorPubkey,
+ reviewerAmount: split.reviewer,
+ platformCommission: split.platform,
+ paymentHash,
+ timestamp: new Date().toISOString(),
+ })
+ } catch (error) {
+ console.error('Error tracking review reward', {
+ reviewId: request.reviewId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ timestamp: new Date().toISOString(),
+ })
+ }
+}
diff --git a/lib/reviewRewardTransfer.ts b/lib/reviewRewardTransfer.ts
new file mode 100644
index 0000000..251c387
--- /dev/null
+++ b/lib/reviewRewardTransfer.ts
@@ -0,0 +1,37 @@
+import { automaticTransferService } from './automaticTransfer'
+import { lightningAddressService } from './lightningAddress'
+import type { ReviewRewardRequest } from './reviewRewardTypes'
+
+export async function transferReviewerPortionIfAvailable(
+ request: ReviewRewardRequest,
+ split: { total: number; reviewer: number; platform: number }
+): Promise {
+ let reviewerLightningAddress: string | undefined = request.reviewerLightningAddress
+ if (!reviewerLightningAddress) {
+ const address = await lightningAddressService.getLightningAddress(request.reviewerPubkey)
+ reviewerLightningAddress = address ?? undefined
+ }
+
+ if (reviewerLightningAddress) {
+ const transferResult = await automaticTransferService.transferReviewerPortion(
+ reviewerLightningAddress,
+ request.reviewId,
+ request.reviewerPubkey,
+ split.total
+ )
+
+ if (!transferResult.success) {
+ console.error('Failed to transfer reviewer portion', {
+ reviewId: request.reviewId,
+ error: transferResult.error,
+ timestamp: new Date().toISOString(),
+ })
+ }
+ } else {
+ console.warn('Reviewer Lightning address not available for automatic transfer', {
+ reviewId: request.reviewId,
+ reviewerPubkey: request.reviewerPubkey,
+ timestamp: new Date().toISOString(),
+ })
+ }
+}
diff --git a/lib/reviewRewardTypes.ts b/lib/reviewRewardTypes.ts
new file mode 100644
index 0000000..08c0f0c
--- /dev/null
+++ b/lib/reviewRewardTypes.ts
@@ -0,0 +1,22 @@
+import type { AlbyInvoice } from '@/types/alby'
+
+export interface ReviewRewardRequest {
+ reviewId: string
+ articleId: string
+ reviewerPubkey: string
+ reviewerLightningAddress?: string
+ authorPubkey: string
+ authorPrivateKey: string
+}
+
+export interface ReviewRewardResult {
+ success: boolean
+ invoice?: AlbyInvoice
+ paymentHash?: string
+ error?: string
+ split: {
+ reviewer: number
+ platform: number
+ total: number
+ }
+}
diff --git a/lib/reviewRewardUpdate.ts b/lib/reviewRewardUpdate.ts
new file mode 100644
index 0000000..5bfa5f2
--- /dev/null
+++ b/lib/reviewRewardUpdate.ts
@@ -0,0 +1,125 @@
+import { nostrService } from './nostr'
+import { PLATFORM_COMMISSIONS } from './platformCommissions'
+import type { Event } from 'nostr-tools'
+
+export async function fetchOriginalReviewEvent(reviewId: string): Promise {
+ const pool = nostrService.getPool()
+ if (!pool) {
+ throw new Error('Pool not initialized')
+ }
+
+ const poolWithSub = pool as import('@/types/nostr-tools-extended').SimplePoolWithSub
+ const { getPrimaryRelaySync } = await import('./config')
+ const relayUrl = getPrimaryRelaySync()
+ const filters = [
+ {
+ kinds: [1],
+ ids: [reviewId],
+ limit: 1,
+ },
+ ]
+
+ return new Promise((resolve) => {
+ let resolved = false
+ const sub = poolWithSub.sub([relayUrl], filters)
+
+ const finalize = (value: Event | null) => {
+ if (resolved) {
+ return
+ }
+ resolved = true
+ sub.unsub()
+ resolve(value)
+ }
+
+ sub.on('event', (event: Event) => {
+ finalize(event)
+ })
+
+ sub.on('eose', () => finalize(null))
+ setTimeout(() => finalize(null), 5000)
+ })
+}
+
+export function buildRewardEvent(originalEvent: Event, reviewId: string): {
+ kind: number
+ created_at: number
+ tags: string[][]
+ content: string
+} {
+ return {
+ kind: 1,
+ created_at: Math.floor(Date.now() / 1000),
+ tags: [
+ ...originalEvent.tags.filter((tag) => tag[0] !== 'rewarded' && tag[0] !== 'reward_amount'),
+ ['e', reviewId],
+ ['rewarded', 'true'],
+ ['reward_amount', PLATFORM_COMMISSIONS.review.total.toString()],
+ ],
+ content: originalEvent.content,
+ }
+}
+
+export function checkIfAlreadyRewarded(originalEvent: Event, reviewId: string): boolean {
+ const alreadyRewarded = originalEvent.tags.some((tag) => tag[0] === 'rewarded' && tag[1] === 'true')
+ if (alreadyRewarded) {
+ console.log('Review already marked as rewarded', {
+ reviewId,
+ timestamp: new Date().toISOString(),
+ })
+ }
+ return alreadyRewarded
+}
+
+export async function publishRewardEvent(
+ updatedEvent: {
+ kind: number
+ created_at: number
+ tags: string[][]
+ content: string
+ },
+ reviewId: string
+): Promise {
+ const publishedEvent = await nostrService.publishEvent(updatedEvent)
+ if (publishedEvent) {
+ console.log('Review updated with reward tag', {
+ reviewId,
+ updatedEventId: publishedEvent.id,
+ timestamp: new Date().toISOString(),
+ })
+ } else {
+ console.error('Failed to publish updated review event', {
+ reviewId,
+ timestamp: new Date().toISOString(),
+ })
+ }
+}
+
+export async function updateReviewWithReward(reviewId: string, authorPrivateKey: string): Promise {
+ try {
+ const originalEvent = await fetchOriginalReviewEvent(reviewId)
+ if (!originalEvent) {
+ console.error('Original review event not found', {
+ reviewId,
+ timestamp: new Date().toISOString(),
+ })
+ return
+ }
+
+ if (checkIfAlreadyRewarded(originalEvent, reviewId)) {
+ return
+ }
+
+ nostrService.setPrivateKey(authorPrivateKey)
+ nostrService.setPublicKey(originalEvent.pubkey)
+
+ const updatedEvent = buildRewardEvent(originalEvent, reviewId)
+ await publishRewardEvent(updatedEvent, reviewId)
+ } catch (error) {
+ console.error('Error updating review with reward', {
+ reviewId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ timestamp: new Date().toISOString(),
+ })
+ }
+}
diff --git a/lib/reviews.ts b/lib/reviews.ts
index 0facd34..291422a 100644
--- a/lib/reviews.ts
+++ b/lib/reviews.ts
@@ -4,15 +4,9 @@ import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import type { Review } from '@/types/nostr'
import { parseReviewFromEvent } from './nostrEventParsing'
import { buildTagFilter } from './nostrTagSystem'
+import { getPrimaryRelaySync } from './config'
-const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
-
-export function getReviewsForArticle(articleId: string, timeoutMs: number = 5000): Promise {
- const pool = nostrService.getPool()
- if (!pool) {
- throw new Error('Pool not initialized')
- }
- const poolWithSub = pool as SimplePoolWithSub
+function buildReviewFilters(articleId: string) {
const tagFilter = buildTagFilter({
type: 'quote',
articleId,
@@ -31,11 +25,21 @@ export function getReviewsForArticle(articleId: string, timeoutMs: number = 5000
if (tagFilter['#article']) {
filterObj['#article'] = tagFilter['#article'] as string[]
}
- const filters = [filterObj]
+ return [filterObj]
+}
+
+export function getReviewsForArticle(articleId: string, timeoutMs: number = 5000): Promise {
+ const pool = nostrService.getPool()
+ if (!pool) {
+ throw new Error('Pool not initialized')
+ }
+ const poolWithSub = pool as SimplePoolWithSub
+ const filters = buildReviewFilters(articleId)
return new Promise((resolve) => {
const results: Review[] = []
- const sub = poolWithSub.sub([RELAY_URL], filters)
+ const relayUrl = getPrimaryRelaySync()
+ const sub = poolWithSub.sub([relayUrl], filters)
let finished = false
const done = () => {
diff --git a/lib/seriesQueries.ts b/lib/seriesQueries.ts
index 31c816a..29aab61 100644
--- a/lib/seriesQueries.ts
+++ b/lib/seriesQueries.ts
@@ -4,8 +4,22 @@ import type { SimplePoolWithSub } from '@/types/nostr-tools-extended'
import type { Series } from '@/types/nostr'
import { parseSeriesFromEvent } from './nostrEventParsing'
import { buildTagFilter } from './nostrTagSystem'
+import { getPrimaryRelaySync } from './config'
-const RELAY_URL = process.env.NEXT_PUBLIC_NOSTR_RELAY_URL ?? 'wss://relay.damus.io'
+function buildSeriesFilters(authorPubkey: string) {
+ const tagFilter = buildTagFilter({
+ type: 'series',
+ authorPubkey,
+ })
+
+ return [
+ {
+ kinds: tagFilter.kinds as number[],
+ ...(tagFilter.authors ? { authors: tagFilter.authors as string[] } : {}),
+ ...(tagFilter['#series'] ? { '#series': tagFilter['#series'] as string[] } : {}),
+ },
+ ]
+}
export function getSeriesByAuthor(authorPubkey: string, timeoutMs: number = 5000): Promise {
const pool = nostrService.getPool()
@@ -13,26 +27,12 @@ export function getSeriesByAuthor(authorPubkey: string, timeoutMs: number = 5000
throw new Error('Pool not initialized')
}
const poolWithSub = pool as SimplePoolWithSub
- const tagFilter = buildTagFilter({
- type: 'series',
- authorPubkey,
- })
-
- const filters: Array<{
- kinds: number[]
- authors?: string[]
- '#series'?: string[]
- }> = [
- {
- kinds: tagFilter.kinds as number[],
- ...(tagFilter.authors ? { authors: tagFilter.authors as string[] } : {}),
- ...(tagFilter['#series'] ? { '#series': tagFilter['#series'] as string[] } : {}),
- },
- ]
+ const filters = buildSeriesFilters(authorPubkey)
return new Promise((resolve) => {
const results: Series[] = []
- const sub = poolWithSub.sub([RELAY_URL], filters)
+ const relayUrl = getPrimaryRelaySync()
+ const sub = poolWithSub.sub([relayUrl], filters)
let finished = false
const done = () => {
@@ -56,13 +56,8 @@ export function getSeriesByAuthor(authorPubkey: string, timeoutMs: number = 5000
})
}
-export function getSeriesById(seriesId: string, timeoutMs: number = 5000): Promise {
- const pool = nostrService.getPool()
- if (!pool) {
- throw new Error('Pool not initialized')
- }
- const poolWithSub = pool as SimplePoolWithSub
- const filters = [
+function buildSeriesByIdFilters(seriesId: string) {
+ return [
{
kinds: [1],
ids: [seriesId],
@@ -71,9 +66,19 @@ export function getSeriesById(seriesId: string, timeoutMs: number = 5000): Promi
}),
},
]
+}
+
+export function getSeriesById(seriesId: string, timeoutMs: number = 5000): Promise