From d3ff2ce84e8702bf5fd9577520ef2eed7f9acc35 Mon Sep 17 00:00:00 2001 From: feruzm Date: Sat, 14 Mar 2026 07:31:46 +0200 Subject: [PATCH 1/5] Self host staging deploy --- .github/workflows/self-hosted.yml | 161 +++++++ apps/self-hosted/DEPLOYMENT.md | 20 +- apps/self-hosted/hosting/api/package.json | 10 +- .../hosting/api/src/payment-listener.ts | 13 + .../hosting/api/src/routes/auth.ts | 8 + .../hosting/api/src/routes/domains.ts | 25 ++ .../hosting/api/src/routes/tenants.ts | 42 +- .../hosting/api/src/services/audit-service.ts | 36 ++ apps/self-hosted/hosting/api/tsconfig.json | 2 +- apps/self-hosted/hosting/api/types.test.ts | 173 ++++++++ apps/self-hosted/hosting/api/vitest.config.ts | 9 + apps/self-hosted/hosting/docker-compose.yml | 20 +- apps/self-hosted/package.json | 6 +- .../src/features/floating-menu/utils.test.ts | 66 +++ .../hosting/components/hosting-signup.tsx | 396 ++++++++++++++++++ .../features/publish/utils/permlink.test.ts | 52 +++ apps/self-hosted/src/routes/hosting.tsx | 14 + .../src/utils/rss-feed-url.test.ts | 28 ++ apps/self-hosted/vitest.config.ts | 12 + 19 files changed, 1065 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/self-hosted.yml create mode 100644 apps/self-hosted/hosting/api/src/services/audit-service.ts create mode 100644 apps/self-hosted/hosting/api/types.test.ts create mode 100644 apps/self-hosted/hosting/api/vitest.config.ts create mode 100644 apps/self-hosted/src/features/floating-menu/utils.test.ts create mode 100644 apps/self-hosted/src/features/hosting/components/hosting-signup.tsx create mode 100644 apps/self-hosted/src/features/publish/utils/permlink.test.ts create mode 100644 apps/self-hosted/src/routes/hosting.tsx create mode 100644 apps/self-hosted/src/utils/rss-feed-url.test.ts create mode 100644 apps/self-hosted/vitest.config.ts diff --git a/.github/workflows/self-hosted.yml b/.github/workflows/self-hosted.yml new file mode 100644 index 0000000000..2ba302a585 --- /dev/null +++ b/.github/workflows/self-hosted.yml @@ -0,0 +1,161 @@ +name: Self-Hosted CI/CD + +on: + push: + branches: + - develop + - main + paths: + - 'apps/self-hosted/**' + - 'packages/**' + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.x] + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v3 + with: + version: 10.18.1 + run_install: false + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - name: Install dependencies + run: pnpm install --frozen-lockfile + env: + CI: true + - name: Run self-hosted tests + run: pnpm --filter @ecency/self-hosted test + - name: Run hosting API tests + run: cd apps/self-hosted/hosting/api && npm install && npm test + + build-blog: + needs: tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Login to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Determine tag + id: tag + run: | + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "tag=latest" >> $GITHUB_OUTPUT + else + echo "tag=develop" >> $GITHUB_OUTPUT + fi + + - name: Build and push blog image + uses: docker/build-push-action@v2 + with: + context: . + file: ./apps/self-hosted/Dockerfile + push: true + tags: ecency/self-hosted:${{ steps.tag.outputs.tag }} + build-args: | + PNPM_VERSION=10.18.1 + + build-api: + needs: tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Login to Docker Hub + uses: docker/login-action@v1 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Determine tag + id: tag + run: | + if [ "${{ github.ref }}" = "refs/heads/main" ]; then + echo "tag=latest" >> $GITHUB_OUTPUT + else + echo "tag=develop" >> $GITHUB_OUTPUT + fi + + - name: Build and push API image + uses: docker/build-push-action@v2 + with: + context: ./apps/self-hosted/hosting/api + file: ./apps/self-hosted/hosting/api/Dockerfile + push: true + tags: ecency/hosting-api:${{ steps.tag.outputs.tag }} + + deploy-staging: + needs: [build-blog, build-api] + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/develop' + steps: + - name: Deploy to staging + uses: appleboy/ssh-action@v1.0.3 + env: + POSTGRES_PASSWORD: ${{ secrets.HOSTING_POSTGRES_PASSWORD }} + JWT_SECRET: ${{ secrets.HOSTING_JWT_SECRET }} + PAYMENT_ACCOUNT: ${{ secrets.HOSTING_PAYMENT_ACCOUNT }} + ACME_EMAIL: ${{ secrets.HOSTING_ACME_EMAIL }} + CF_API_EMAIL: ${{ secrets.CF_API_EMAIL }} + CF_API_KEY: ${{ secrets.CF_API_KEY }} + with: + host: ${{ secrets.SSH_HOST_HOSTING_STAGING }} + username: ${{ secrets.SSH_USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.SSH_PORT }} + envs: POSTGRES_PASSWORD,JWT_SECRET,PAYMENT_ACCOUNT,ACME_EMAIL,CF_API_EMAIL,CF_API_KEY + script: | + export POSTGRES_PASSWORD=$POSTGRES_PASSWORD + export JWT_SECRET=$JWT_SECRET + export PAYMENT_ACCOUNT=$PAYMENT_ACCOUNT + export ACME_EMAIL=$ACME_EMAIL + export CF_API_EMAIL=$CF_API_EMAIL + export CF_API_KEY=$CF_API_KEY + cd ~/hosting + docker pull ecency/self-hosted:develop + docker pull ecency/hosting-api:develop + TAG=develop docker compose up -d + + deploy: + needs: [build-blog, build-api] + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' + steps: + - name: Deploy to production + uses: appleboy/ssh-action@v1.0.3 + env: + POSTGRES_PASSWORD: ${{ secrets.HOSTING_POSTGRES_PASSWORD }} + JWT_SECRET: ${{ secrets.HOSTING_JWT_SECRET }} + PAYMENT_ACCOUNT: ${{ secrets.HOSTING_PAYMENT_ACCOUNT }} + ACME_EMAIL: ${{ secrets.HOSTING_ACME_EMAIL }} + CF_API_EMAIL: ${{ secrets.CF_API_EMAIL }} + CF_API_KEY: ${{ secrets.CF_API_KEY }} + with: + host: ${{ secrets.SSH_HOST_HOSTING }} + username: ${{ secrets.SSH_USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.SSH_PORT }} + envs: POSTGRES_PASSWORD,JWT_SECRET,PAYMENT_ACCOUNT,ACME_EMAIL,CF_API_EMAIL,CF_API_KEY + script: | + export POSTGRES_PASSWORD=$POSTGRES_PASSWORD + export JWT_SECRET=$JWT_SECRET + export PAYMENT_ACCOUNT=$PAYMENT_ACCOUNT + export ACME_EMAIL=$ACME_EMAIL + export CF_API_EMAIL=$CF_API_EMAIL + export CF_API_KEY=$CF_API_KEY + cd ~/hosting + docker pull ecency/self-hosted:latest + docker pull ecency/hosting-api:latest + TAG=latest docker compose up -d diff --git a/apps/self-hosted/DEPLOYMENT.md b/apps/self-hosted/DEPLOYMENT.md index 77fcef0af6..c224e5a243 100644 --- a/apps/self-hosted/DEPLOYMENT.md +++ b/apps/self-hosted/DEPLOYMENT.md @@ -417,32 +417,26 @@ docker-compose build --no-cache ## Managed Hosting by Ecency -> **⚠️ PLANNED - NOT YET AVAILABLE** -> -> The managed hosting service described below is under development and not yet launched. -> The endpoints, payment accounts, and features listed are placeholders for the planned service. -> Check [ecency.com](https://ecency.com) for announcements when this service becomes available. - Don't want to manage your own infrastructure? Let Ecency host your blog. -### Planned Pricing +### Pricing | Plan | Price | Features | |------|-------|----------| | **Standard** | 1 HBD/month | Custom subdomain, SSL, CDN, 99.9% uptime | | **Pro** | 3 HBD/month | Custom domain, priority support, analytics | -### How It Will Work (Planned) +### How It Works -1. **Visit** the Ecency blog hosting page (URL TBD) +1. **Visit** [https://blogs.ecency.com](https://blogs.ecency.com) 2. **Connect** your Hive wallet 3. **Configure** your blog (username, theme, features) 4. **Pay** via HBD transfer 5. **Go live** instantly! -### Custom Domain Setup (Planned) +### Custom Domain Setup -For custom domains, you would add a CNAME record: +For custom domains, add a CNAME record: ``` Type: CNAME @@ -451,10 +445,10 @@ Value: YOUR-BLOG-ID.blogs.ecency.com TTL: 3600 ``` -### Payment Memo Format (Planned) +### Payment Memo Format ``` -To: (TBD - payment account not yet active) +To: ecency.hosting Amount: 1.000 HBD Memo: blog:YOUR_HIVE_USERNAME ``` diff --git a/apps/self-hosted/hosting/api/package.json b/apps/self-hosted/hosting/api/package.json index 15a50c4115..b70377fc6e 100644 --- a/apps/self-hosted/hosting/api/package.json +++ b/apps/self-hosted/hosting/api/package.json @@ -7,12 +7,16 @@ "build": "tsc", "start": "node dist/index.js", "start:listener": "node dist/payment-listener.js", - "db:migrate": "tsx src/db/migrate.ts" + "db:migrate": "tsx src/db/migrate.ts", + "test": "vitest run" }, "dependencies": { "@hiveio/dhive": "^1.3.5", "@hiveio/x402": "^0.1.2", + "@hono/node-server": "^1.13.0", + "@hono/zod-validator": "^0.4.0", "hono": "^4.4.0", + "jsonwebtoken": "^9.0.0", "pg": "^8.12.0", "redis": "^4.6.0", "zod": "^3.23.0", @@ -20,9 +24,11 @@ "nanoid": "^5.0.0" }, "devDependencies": { + "@types/jsonwebtoken": "^9.0.0", "@types/node": "^20.14.0", "@types/pg": "^8.11.0", "tsx": "^4.15.0", - "typescript": "^5.5.0" + "typescript": "^5.5.0", + "vitest": "^3.0.0" } } diff --git a/apps/self-hosted/hosting/api/src/payment-listener.ts b/apps/self-hosted/hosting/api/src/payment-listener.ts index b58e9545bc..3107ef7be3 100644 --- a/apps/self-hosted/hosting/api/src/payment-listener.ts +++ b/apps/self-hosted/hosting/api/src/payment-listener.ts @@ -10,6 +10,7 @@ import { db } from './db/client'; import { TenantService } from './services/tenant-service'; import { ConfigService } from './services/config-service'; import { parseMemo, type ParsedMemo } from '../types'; +import { AuditService } from './services/audit-service'; // Configuration const CONFIG = { @@ -281,6 +282,12 @@ class PaymentListener { [paymentId, updatedTenant.id, updatedTenant.subscriptionExpiresAt] ); + void AuditService.log({ + tenantId: updatedTenant.id, + eventType: 'payment.processed', + eventData: { username, months, amount, trxId: transfer.trxId }, + }); + console.log( '[PaymentListener] Subscription activated for', username, @@ -320,6 +327,12 @@ class PaymentListener { await TenantService.upgradeToPro(username); await this.logPayment(transfer, amount, 'processed', 0, null, 'Upgraded to Pro'); + void AuditService.log({ + tenantId: tenant.id, + eventType: 'payment.upgrade', + eventData: { username, amount, trxId: transfer.trxId }, + }); + console.log('[PaymentListener] Upgraded', username, 'to Pro plan'); } catch (error) { console.error('[PaymentListener] Failed to process upgrade for', username, error); diff --git a/apps/self-hosted/hosting/api/src/routes/auth.ts b/apps/self-hosted/hosting/api/src/routes/auth.ts index a04aa38c30..1cc0416218 100644 --- a/apps/self-hosted/hosting/api/src/routes/auth.ts +++ b/apps/self-hosted/hosting/api/src/routes/auth.ts @@ -12,6 +12,7 @@ import { TenantService } from '../services/tenant-service'; import { nanoid } from 'nanoid'; import { createToken, verifyToken, getTokenExpiry } from '../utils/auth'; import { challengeStore } from '../utils/redis'; +import { AuditService } from '../services/audit-service'; export const authRoutes = new Hono(); @@ -110,6 +111,13 @@ authRoutes.post( const token = createToken(username, expiresInMs); const expiresAt = getTokenExpiry(token); + void AuditService.log({ + eventType: 'auth.login', + eventData: { username }, + ipAddress: c.req.header('x-forwarded-for'), + userAgent: c.req.header('user-agent'), + }); + return c.json({ token, username, diff --git a/apps/self-hosted/hosting/api/src/routes/domains.ts b/apps/self-hosted/hosting/api/src/routes/domains.ts index ab722c6cd8..004b76117c 100644 --- a/apps/self-hosted/hosting/api/src/routes/domains.ts +++ b/apps/self-hosted/hosting/api/src/routes/domains.ts @@ -8,6 +8,7 @@ import { zValidator } from '@hono/zod-validator'; import { TenantService } from '../services/tenant-service'; import { DomainService } from '../services/domain-service'; import { authMiddleware } from '../middleware/auth'; +import { AuditService } from '../services/audit-service'; export const domainRoutes = new Hono(); @@ -46,6 +47,14 @@ domainRoutes.post('/', authMiddleware, zValidator('json', addDomainSchema), asyn await TenantService.setCustomDomain(username, domain); const verification = await DomainService.createVerification(username, domain); + void AuditService.log({ + tenantId: tenant.id, + eventType: 'domain.added', + eventData: { domain, username }, + ipAddress: c.req.header('x-forwarded-for'), + userAgent: c.req.header('user-agent'), + }); + return c.json({ domain, verification: { @@ -89,6 +98,14 @@ domainRoutes.post('/verify', authMiddleware, async (c) => { await TenantService.verifyCustomDomain(username); await DomainService.markVerified(username, tenant.customDomain); + void AuditService.log({ + tenantId: tenant.id, + eventType: 'domain.verified', + eventData: { domain: tenant.customDomain, username }, + ipAddress: c.req.header('x-forwarded-for'), + userAgent: c.req.header('user-agent'), + }); + return c.json({ verified: true, domain: tenant.customDomain, @@ -103,6 +120,14 @@ domainRoutes.delete('/', authMiddleware, async (c) => { try { await TenantService.removeCustomDomain(username); + + void AuditService.log({ + eventType: 'domain.removed', + eventData: { username }, + ipAddress: c.req.header('x-forwarded-for'), + userAgent: c.req.header('user-agent'), + }); + return c.json({ message: 'Custom domain removed' }); } catch (error: any) { if (error.message === 'Tenant not found') { diff --git a/apps/self-hosted/hosting/api/src/routes/tenants.ts b/apps/self-hosted/hosting/api/src/routes/tenants.ts index 240cf2a88d..7a4ebd60da 100644 --- a/apps/self-hosted/hosting/api/src/routes/tenants.ts +++ b/apps/self-hosted/hosting/api/src/routes/tenants.ts @@ -11,6 +11,7 @@ import { mapTenantFromDb } from '../../types'; import { ConfigService } from '../services/config-service'; import { authMiddleware } from '../middleware/auth'; import { subscriptionPaywall, proUpgradePaywall } from '../middleware/x402-paywall'; +import { AuditService } from '../services/audit-service'; export const tenantRoutes = new Hono(); @@ -104,6 +105,14 @@ tenantRoutes.post('/', zValidator('json', createTenantSchema), async (c) => { const paymentAccount = process.env.PAYMENT_ACCOUNT || 'ecency.hosting'; const monthlyPrice = process.env.MONTHLY_PRICE_HBD || '1.000'; + void AuditService.log({ + tenantId: tenant.id, + eventType: 'tenant.created', + eventData: { username: body.username }, + ipAddress: c.req.header('x-forwarded-for'), + userAgent: c.req.header('user-agent'), + }); + return c.json({ tenant: { username: tenant.username, @@ -221,6 +230,14 @@ tenantRoutes.post('/subscribe', console.error(`Failed to generate config for ${body.username}:`, err); } + void AuditService.log({ + tenantId: activatedTenant.id, + eventType: 'tenant.subscribed', + eventData: { username: body.username, payer, txId }, + ipAddress: c.req.header('x-forwarded-for'), + userAgent: c.req.header('user-agent'), + }); + return c.json({ tenant: { username: activatedTenant.username, @@ -337,6 +354,14 @@ tenantRoutes.post('/:username/upgrade', const upgradedTenant = mapTenantFromDb(result); + void AuditService.log({ + tenantId: upgradedTenant.id, + eventType: 'tenant.upgraded', + eventData: { username, plan: 'pro', payer, txId }, + ipAddress: c.req.header('x-forwarded-for'), + userAgent: c.req.header('user-agent'), + }); + return c.json({ tenant: { username: upgradedTenant.username, @@ -370,6 +395,14 @@ tenantRoutes.patch('/:username', authMiddleware, zValidator('json', updateTenant // Regenerate config file await ConfigService.generateConfigFile(updatedTenant); + void AuditService.log({ + tenantId: updatedTenant.id, + eventType: 'tenant.config_updated', + eventData: { username }, + ipAddress: c.req.header('x-forwarded-for'), + userAgent: c.req.header('user-agent'), + }); + return c.json({ username: updatedTenant.username, config: updatedTenant.config, @@ -417,7 +450,14 @@ tenantRoutes.delete('/:username', authMiddleware, async (c) => { await TenantService.delete(username); await ConfigService.deleteConfigFile(username); - + + void AuditService.log({ + eventType: 'tenant.deleted', + eventData: { username }, + ipAddress: c.req.header('x-forwarded-for'), + userAgent: c.req.header('user-agent'), + }); + return c.json({ message: 'Tenant deleted' }); }); diff --git a/apps/self-hosted/hosting/api/src/services/audit-service.ts b/apps/self-hosted/hosting/api/src/services/audit-service.ts new file mode 100644 index 0000000000..f562b3aad3 --- /dev/null +++ b/apps/self-hosted/hosting/api/src/services/audit-service.ts @@ -0,0 +1,36 @@ +/** + * Audit Logging Service + * + * Fire-and-forget inserts into the audit_log table. + * Never throws — errors are logged but swallowed so callers are unaffected. + */ + +import { db } from '../db/client'; + +export interface AuditEntry { + tenantId?: string | null; + eventType: string; + eventData?: Record; + ipAddress?: string | null; + userAgent?: string | null; +} + +export const AuditService = { + log(entry: AuditEntry): void { + const { tenantId, eventType, eventData, ipAddress, userAgent } = entry; + + db.query( + `INSERT INTO audit_log (tenant_id, event_type, event_data, ip_address, user_agent) + VALUES ($1, $2, $3, $4, $5)`, + [ + tenantId ?? null, + eventType, + eventData ? JSON.stringify(eventData) : null, + ipAddress ?? null, + userAgent ?? null, + ], + ).catch((err) => { + console.error('[AuditService] Failed to write audit log:', err); + }); + }, +}; diff --git a/apps/self-hosted/hosting/api/tsconfig.json b/apps/self-hosted/hosting/api/tsconfig.json index 792b85524c..536357b3e9 100644 --- a/apps/self-hosted/hosting/api/tsconfig.json +++ b/apps/self-hosted/hosting/api/tsconfig.json @@ -15,6 +15,6 @@ "declarationMap": true, "sourceMap": true }, - "include": ["src/**/*"], + "include": ["src/**/*", "types.ts", "types.test.ts"], "exclude": ["node_modules", "dist"] } diff --git a/apps/self-hosted/hosting/api/types.test.ts b/apps/self-hosted/hosting/api/types.test.ts new file mode 100644 index 0000000000..0a408b64d2 --- /dev/null +++ b/apps/self-hosted/hosting/api/types.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest'; +import { + parseMemo, + mapTenantFromDb, + mapTenantToDb, + type TenantRow, + type Tenant, +} from './types'; + +// ============================================================================= +// parseMemo +// ============================================================================= + +describe('parseMemo', () => { + it('parses "blog:username" as 1-month subscription', () => { + const result = parseMemo('blog:alice'); + expect(result).toEqual({ action: 'blog', username: 'alice', months: 1 }); + }); + + it('parses "blog:username:months" with explicit months', () => { + const result = parseMemo('blog:bob:6'); + expect(result).toEqual({ action: 'blog', username: 'bob', months: 6 }); + }); + + it('parses "blog:username:12" for a yearly subscription', () => { + const result = parseMemo('blog:charlie:12'); + expect(result).toEqual({ action: 'blog', username: 'charlie', months: 12 }); + }); + + it('defaults months to 1 when the months segment is non-numeric', () => { + const result = parseMemo('blog:dave:abc'); + expect(result).toEqual({ action: 'blog', username: 'dave', months: 1 }); + }); + + it('handles uppercase memos by lowercasing', () => { + const result = parseMemo('BLOG:Alice'); + expect(result).toEqual({ action: 'blog', username: 'alice', months: 1 }); + }); + + it('trims whitespace around the memo', () => { + const result = parseMemo(' blog:alice '); + expect(result).toEqual({ action: 'blog', username: 'alice', months: 1 }); + }); + + it('parses "upgrade:username"', () => { + const result = parseMemo('upgrade:alice'); + expect(result).toEqual({ action: 'upgrade', username: 'alice', months: 1 }); + }); + + it('returns unknown for empty string', () => { + const result = parseMemo(''); + expect(result).toEqual({ action: 'unknown', username: '', months: 0 }); + }); + + it('returns unknown for random text', () => { + const result = parseMemo('hello world'); + expect(result).toEqual({ action: 'unknown', username: '', months: 0 }); + }); + + it('returns unknown when action is "blog" but username is missing', () => { + const result = parseMemo('blog:'); + expect(result).toEqual({ action: 'unknown', username: '', months: 0 }); + }); + + it('returns unknown when action is "upgrade" but username is missing', () => { + const result = parseMemo('upgrade:'); + expect(result).toEqual({ action: 'unknown', username: '', months: 0 }); + }); + + it('returns unknown for unrecognized action', () => { + const result = parseMemo('refund:alice'); + expect(result).toEqual({ action: 'unknown', username: '', months: 0 }); + }); +}); + +// ============================================================================= +// mapTenantFromDb +// ============================================================================= + +describe('mapTenantFromDb', () => { + const baseRow: TenantRow = { + id: '123', + username: 'alice', + subscription_status: 'active', + subscription_plan: 'standard', + subscription_started_at: '2025-01-01T00:00:00Z', + subscription_expires_at: '2025-02-01T00:00:00Z', + custom_domain: 'alice.blog', + custom_domain_verified: true, + custom_domain_verified_at: '2025-01-05T00:00:00Z', + config: { version: 1 }, + created_at: '2025-01-01T00:00:00Z', + updated_at: '2025-01-01T00:00:00Z', + }; + + it('maps snake_case fields to camelCase', () => { + const tenant = mapTenantFromDb(baseRow); + expect(tenant.id).toBe('123'); + expect(tenant.username).toBe('alice'); + expect(tenant.subscriptionStatus).toBe('active'); + expect(tenant.subscriptionPlan).toBe('standard'); + expect(tenant.customDomain).toBe('alice.blog'); + expect(tenant.customDomainVerified).toBe(true); + }); + + it('converts date strings to Date objects', () => { + const tenant = mapTenantFromDb(baseRow); + expect(tenant.subscriptionStartedAt).toBeInstanceOf(Date); + expect(tenant.subscriptionExpiresAt).toBeInstanceOf(Date); + expect(tenant.customDomainVerifiedAt).toBeInstanceOf(Date); + expect(tenant.createdAt).toBeInstanceOf(Date); + expect(tenant.updatedAt).toBeInstanceOf(Date); + }); + + it('handles null date fields', () => { + const row: TenantRow = { + ...baseRow, + subscription_started_at: null, + subscription_expires_at: null, + custom_domain: null, + custom_domain_verified: false, + custom_domain_verified_at: null, + }; + const tenant = mapTenantFromDb(row); + expect(tenant.subscriptionStartedAt).toBeNull(); + expect(tenant.subscriptionExpiresAt).toBeNull(); + expect(tenant.customDomain).toBeNull(); + expect(tenant.customDomainVerifiedAt).toBeNull(); + }); + + it('preserves the config object as-is', () => { + const tenant = mapTenantFromDb(baseRow); + expect(tenant.config).toEqual({ version: 1 }); + }); +}); + +// ============================================================================= +// mapTenantToDb +// ============================================================================= + +describe('mapTenantToDb', () => { + it('maps camelCase to snake_case for all fields', () => { + const partial: Partial = { + username: 'bob', + subscriptionStatus: 'active', + subscriptionPlan: 'pro', + }; + const dbFields = mapTenantToDb(partial); + expect(dbFields).toEqual({ + username: 'bob', + subscription_status: 'active', + subscription_plan: 'pro', + }); + }); + + it('only includes fields that are defined', () => { + const dbFields = mapTenantToDb({ username: 'bob' }); + expect(Object.keys(dbFields)).toEqual(['username']); + }); + + it('JSON.stringifies the config field', () => { + const partial: Partial = { + config: { version: 1 } as any, + }; + const dbFields = mapTenantToDb(partial); + expect(dbFields.config).toBe(JSON.stringify({ version: 1 })); + }); + + it('returns empty object when nothing is set', () => { + const dbFields = mapTenantToDb({}); + expect(dbFields).toEqual({}); + }); +}); diff --git a/apps/self-hosted/hosting/api/vitest.config.ts b/apps/self-hosted/hosting/api/vitest.config.ts new file mode 100644 index 0000000000..cf3455c102 --- /dev/null +++ b/apps/self-hosted/hosting/api/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + globals: true, + include: ['**/*.test.ts'], + }, +}); diff --git a/apps/self-hosted/hosting/docker-compose.yml b/apps/self-hosted/hosting/docker-compose.yml index d1c1c04921..8426c57e10 100644 --- a/apps/self-hosted/hosting/docker-compose.yml +++ b/apps/self-hosted/hosting/docker-compose.yml @@ -74,9 +74,10 @@ services: # Blog Server - Serves static SPA with multi-tenant config routing # ========================================================================== blog-server: - build: - context: ../../.. - dockerfile: apps/self-hosted/Dockerfile + image: ecency/self-hosted:${TAG:-latest} + # build: + # context: ../../.. + # dockerfile: apps/self-hosted/Dockerfile container_name: ecency-hosting-blog restart: unless-stopped volumes: @@ -115,9 +116,10 @@ services: # Hosting API - Tenant management, subscriptions, config generation # ========================================================================== hosting-api: - build: - context: ./api - dockerfile: Dockerfile + image: ecency/hosting-api:${TAG:-latest} + # build: + # context: ./api + # dockerfile: Dockerfile container_name: ecency-hosting-api restart: unless-stopped environment: @@ -150,9 +152,9 @@ services: # HBD Payment Listener - Monitors blockchain for subscription payments # ========================================================================== payment-listener: - build: - context: ./api - dockerfile: Dockerfile.payment-listener + image: ecency/hosting-api:${TAG:-latest} + command: ["node", "dist/payment-listener.js"] + # Reuses API image with different entrypoint container_name: ecency-hosting-payments restart: unless-stopped environment: diff --git a/apps/self-hosted/package.json b/apps/self-hosted/package.json index 1de5525514..afb758f219 100644 --- a/apps/self-hosted/package.json +++ b/apps/self-hosted/package.json @@ -8,7 +8,8 @@ "check": "biome check --write", "dev": "rsbuild dev --open", "format": "biome format --write", - "preview": "rsbuild preview" + "preview": "rsbuild preview", + "test": "vitest run" }, "dependencies": { "@ecency/render-helper": "workspace:*", @@ -62,6 +63,7 @@ "@types/react-dom": "^19.1.9", "@types/speakingurl": "^13.0.6", "tailwindcss": "^4.1.13", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^3.0.0" } } diff --git a/apps/self-hosted/src/features/floating-menu/utils.test.ts b/apps/self-hosted/src/features/floating-menu/utils.test.ts new file mode 100644 index 0000000000..7fb0a15dfe --- /dev/null +++ b/apps/self-hosted/src/features/floating-menu/utils.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { deepClone, updateNestedPath } from './utils'; + +describe('deepClone', () => { + it('creates a deep copy of an object', () => { + const original = { a: 1, b: { c: 2 } }; + const cloned = deepClone(original); + expect(cloned).toEqual(original); + expect(cloned).not.toBe(original); + expect(cloned.b).not.toBe(original.b); + }); + + it('preserves immutability — modifying clone does not affect original', () => { + const original = { a: { b: 1 } }; + const cloned = deepClone(original); + cloned.a.b = 99; + expect(original.a.b).toBe(1); + }); + + it('handles arrays', () => { + const original = { items: [1, 2, 3] }; + const cloned = deepClone(original); + cloned.items.push(4); + expect(original.items).toEqual([1, 2, 3]); + }); + + it('handles null values', () => { + const original = { a: null }; + const cloned = deepClone(original); + expect(cloned.a).toBeNull(); + }); +}); + +describe('updateNestedPath', () => { + it('updates a top-level key', () => { + const obj = { name: 'old' }; + const result = updateNestedPath(obj, 'name', 'new'); + expect(result.name).toBe('new'); + // Original unchanged + expect(obj.name).toBe('old'); + }); + + it('updates a nested key', () => { + const obj = { a: { b: { c: 1 } } }; + const result = updateNestedPath(obj as any, 'a.b.c', 42); + expect((result as any).a.b.c).toBe(42); + }); + + it('creates intermediate objects if they do not exist', () => { + const obj = {}; + const result = updateNestedPath(obj, 'x.y.z', 'created'); + expect((result as any).x.y.z).toBe('created'); + }); + + it('overwrites non-object intermediate values', () => { + const obj = { a: 'string' }; + const result = updateNestedPath(obj as any, 'a.b', 'value'); + expect((result as any).a.b).toBe('value'); + }); + + it('does not mutate the original object', () => { + const obj = { a: { b: 1 } }; + updateNestedPath(obj as any, 'a.b', 2); + expect(obj.a.b).toBe(1); + }); +}); diff --git a/apps/self-hosted/src/features/hosting/components/hosting-signup.tsx b/apps/self-hosted/src/features/hosting/components/hosting-signup.tsx new file mode 100644 index 0000000000..2504b43272 --- /dev/null +++ b/apps/self-hosted/src/features/hosting/components/hosting-signup.tsx @@ -0,0 +1,396 @@ +/** + * Hosting Signup Component + * + * Allows users to sign up for Ecency managed blog hosting + */ + +import { useState, useCallback } from 'react'; + +interface HostingSignupProps { + apiBaseUrl?: string; + onSuccess?: (data: { username: string; blogUrl: string }) => void; + onError?: (error: string) => void; +} + +interface PaymentInstructions { + to: string; + amount: string; + memo: string; +} + +type Step = 'username' | 'configure' | 'payment' | 'success'; + +export function HostingSignup({ + apiBaseUrl = 'https://api.blogs.ecency.com/hosting', + onSuccess, + onError, +}: HostingSignupProps) { + const [step, setStep] = useState('username'); + const [username, setUsername] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [paymentInstructions, setPaymentInstructions] = useState(null); + const [blogUrl, setBlogUrl] = useState(null); + + // Config options + const [config, setConfig] = useState({ + theme: 'system' as 'light' | 'dark' | 'system', + styleTemplate: 'medium' as string, + type: 'blog' as 'blog' | 'community', + title: '', + description: '', + }); + + const checkUsername = useCallback(async () => { + if (!username || username.length < 3) { + setError('Username must be at least 3 characters'); + return; + } + + setIsLoading(true); + setError(null); + + try { + // Check if username is available + const response = await fetch(`${apiBaseUrl}/v1/tenants/${username}/status`); + const data = await response.json(); + + if (data.exists && data.subscriptionStatus === 'active') { + setError('This username already has an active blog'); + return; + } + + // Move to configure step + setConfig((prev) => ({ ...prev, title: `${username}'s Blog` })); + setStep('configure'); + } catch (err) { + setError('Failed to check username. Please try again.'); + onError?.('Failed to check username'); + } finally { + setIsLoading(false); + } + }, [username, apiBaseUrl, onError]); + + const createTenant = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch(`${apiBaseUrl}/v1/tenants`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username, + config: { + theme: config.theme, + styleTemplate: config.styleTemplate, + type: config.type, + title: config.title, + description: config.description, + }, + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to create blog'); + } + + const data = await response.json(); + setPaymentInstructions(data.paymentInstructions); + setBlogUrl(data.tenant.blogUrl); + setStep('payment'); + } catch (err: any) { + setError(err.message || 'Failed to create blog'); + onError?.(err.message); + } finally { + setIsLoading(false); + } + }, [username, config, apiBaseUrl, onError]); + + const checkPayment = useCallback(async () => { + setIsLoading(true); + + try { + const response = await fetch(`${apiBaseUrl}/v1/tenants/${username}/status`); + const data = await response.json(); + + if (data.subscriptionStatus === 'active') { + setStep('success'); + onSuccess?.({ username, blogUrl: blogUrl! }); + } else { + setError('Payment not yet received. Please wait a few seconds and try again.'); + } + } catch (err) { + setError('Failed to check payment status'); + } finally { + setIsLoading(false); + } + }, [username, blogUrl, apiBaseUrl, onSuccess]); + + const sendPaymentWithKeychain = useCallback(async () => { + if (!paymentInstructions) return; + + // Check if Keychain is available + if (typeof window === 'undefined' || !(window as any).hive_keychain) { + setError('Hive Keychain extension not found. Please install it or send payment manually.'); + return; + } + + const keychain = (window as any).hive_keychain; + const [amount] = paymentInstructions.amount.split(' '); + + keychain.requestTransfer( + username, + paymentInstructions.to, + amount, + paymentInstructions.memo, + 'HBD', + (response: any) => { + if (response.success) { + // Wait a bit for blockchain confirmation, then check + setTimeout(checkPayment, 5000); + } else { + setError('Payment cancelled or failed'); + } + } + ); + }, [username, paymentInstructions, checkPayment]); + + return ( +
+

+ Create Your Hive Blog +

+ + {error && ( +
+ {error} +
+ )} + + {/* Step 1: Username */} + {step === 'username' && ( +
+
+ + setUsername(e.target.value.toLowerCase())} + placeholder="your-username" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + disabled={isLoading} + /> +

+ Your blog will be at: {username || 'username'}.blogs.ecency.com +

+
+ + +
+ )} + + {/* Step 2: Configure */} + {step === 'configure' && ( +
+
+ + setConfig((prev) => ({ ...prev, title: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white" + /> +
+ +
+ +