Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions .github/workflows/self-hosted.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
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
# Hosting API is a standalone npm project (not part of pnpm workspace)
# with its own Dockerfile and independent dependency tree
- name: Run hosting API tests
run: cd apps/self-hosted/hosting/api && npm install && npm test
Comment on lines +38 to +39
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent package manager usage in hosting API tests.

The workflow uses pnpm for the monorepo but switches to npm install for the hosting API tests. This breaks consistency and may cause dependency resolution issues if the API has workspace dependencies.

🔧 Proposed fix

If the hosting API is part of the pnpm workspace:

       - name: Run hosting API tests
-        run: cd apps/self-hosted/hosting/api && npm install && npm test
+        run: pnpm --filter `@ecency/hosting-api` test

If it's intentionally separate, consider adding a comment explaining why.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/self-hosted.yml around lines 36 - 37, The workflow step
named "Run hosting API tests" currently runs "cd apps/self-hosted/hosting/api &&
npm install && npm test", causing inconsistent package manager usage; change
that command to use pnpm (e.g., use pnpm install or pnpm install
--frozen-lockfile then pnpm test) so the hosting API uses the monorepo's pnpm
workspace resolution, and if the API is intentionally a standalone npm project
instead of part of the pnpm workspace, add a clarifying comment in the step
explaining why npm is used.


build-blog:
needs: tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Determine tags
id: tag
run: |
SHA_TAG="sha-${GITHUB_SHA::7}"
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
CHANNEL_TAG="latest"
else
CHANNEL_TAG="develop"
fi
echo "sha=$SHA_TAG" >> $GITHUB_OUTPUT
echo "channel=$CHANNEL_TAG" >> $GITHUB_OUTPUT

- name: Build and push blog image
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/self-hosted/Dockerfile
push: true
tags: |
ecency/self-hosted:${{ steps.tag.outputs.sha }}
ecency/self-hosted:${{ steps.tag.outputs.channel }}

build-api:
needs: tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

- name: Determine tags
id: tag
run: |
SHA_TAG="sha-${GITHUB_SHA::7}"
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
CHANNEL_TAG="latest"
else
CHANNEL_TAG="develop"
fi
echo "sha=$SHA_TAG" >> $GITHUB_OUTPUT
echo "channel=$CHANNEL_TAG" >> $GITHUB_OUTPUT

- name: Build and push API image
uses: docker/build-push-action@v6
with:
context: ./apps/self-hosted/hosting/api
file: ./apps/self-hosted/hosting/api/Dockerfile
push: true
tags: |
ecency/hosting-api:${{ steps.tag.outputs.sha }}
ecency/hosting-api:${{ steps.tag.outputs.channel }}

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
env:
DEPLOY_SHA: ${{ github.sha }}
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: DEPLOY_SHA,POSTGRES_PASSWORD,JWT_SECRET,PAYMENT_ACCOUNT,ACME_EMAIL,CF_API_EMAIL,CF_API_KEY
script: |
IMAGE_TAG="sha-${DEPLOY_SHA::7}"
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:$IMAGE_TAG
docker pull ecency/hosting-api:$IMAGE_TAG
TAG=$IMAGE_TAG 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
env:
DEPLOY_SHA: ${{ github.sha }}
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: DEPLOY_SHA,POSTGRES_PASSWORD,JWT_SECRET,PAYMENT_ACCOUNT,ACME_EMAIL,CF_API_EMAIL,CF_API_KEY
script: |
IMAGE_TAG="sha-${DEPLOY_SHA::7}"
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:$IMAGE_TAG
docker pull ecency/hosting-api:$IMAGE_TAG
TAG=$IMAGE_TAG docker compose up -d
20 changes: 7 additions & 13 deletions apps/self-hosted/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```
Expand Down
10 changes: 8 additions & 2 deletions apps/self-hosted/hosting/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,28 @@
"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",
"date-fns": "^3.6.0",
"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"
}
}
14 changes: 13 additions & 1 deletion apps/self-hosted/hosting/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,20 @@ const app = new Hono();
// Middleware
app.use('*', logger());
app.use('*', secureHeaders());
const baseDomain = process.env.BASE_DOMAIN || 'blogs.ecency.com';
app.use('*', cors({
origin: ['https://ecency.com', 'http://localhost:3000'],
origin: (origin) => {
const allowed = [
'https://ecency.com',
'https://alpha.ecency.com',
`https://${baseDomain}`,
'http://localhost:3000',
];
if (allowed.includes(origin)) return origin;
// Allow any subdomain of the base domain (tenant blogs)
if (origin.endsWith(`.${baseDomain}`) && origin.startsWith('https://')) return origin;
return null;
},
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization', 'x-payment'],
exposeHeaders: ['x-payment', 'x-payment-response'],
Expand Down
17 changes: 15 additions & 2 deletions apps/self-hosted/hosting/api/src/payment-listener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { Client } from '@hiveio/dhive';
import { db } from './db/client';
import { TenantService } from './services/tenant-service';
import { ConfigService } from './services/config-service';
import { parseMemo, type ParsedMemo } from '../types';
import { parseMemo, type ParsedMemo } from './types';
import { AuditService } from './services/audit-service';

// Configuration
const CONFIG = {
Expand Down Expand Up @@ -289,9 +290,15 @@ class PaymentListener {
);
});

// 5. Generate config file AFTER transaction commits successfully
// 5. Post-commit side effects (only if transaction succeeded)
if (updatedTenant) {
await ConfigService.generateConfigFile(updatedTenant);

void AuditService.log({
tenantId: updatedTenant.id,
eventType: 'payment.processed',
eventData: { username, months, amount, trxId: transfer.trxId },
});
}
} catch (error) {
console.error('[PaymentListener] Failed to process subscription for', username, error);
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions apps/self-hosted/hosting/api/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, parseClientIp } from '../services/audit-service';

export const authRoutes = new Hono();

Expand Down Expand Up @@ -110,6 +111,13 @@ authRoutes.post(
const token = createToken(username, expiresInMs);
const expiresAt = getTokenExpiry(token);

void AuditService.log({
eventType: 'auth.login',
eventData: { username },
ipAddress: parseClientIp(c.req.header('x-forwarded-for')),
userAgent: c.req.header('user-agent'),
});

return c.json({
token,
username,
Expand Down
27 changes: 27 additions & 0 deletions apps/self-hosted/hosting/api/src/routes/domains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, parseClientIp } from '../services/audit-service';

export const domainRoutes = new Hono();

Expand Down Expand Up @@ -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: parseClientIp(c.req.header('x-forwarded-for')),
userAgent: c.req.header('user-agent'),
});

return c.json({
domain,
verification: {
Expand Down Expand Up @@ -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: parseClientIp(c.req.header('x-forwarded-for')),
userAgent: c.req.header('user-agent'),
});

return c.json({
verified: true,
domain: tenant.customDomain,
Expand All @@ -102,7 +119,17 @@ domainRoutes.delete('/', authMiddleware, async (c) => {
const username = authUser.username;

try {
const tenant = await TenantService.getByUsername(username);
await TenantService.removeCustomDomain(username);

void AuditService.log({
tenantId: tenant?.id ?? null,
eventType: 'domain.removed',
eventData: { username },
ipAddress: parseClientIp(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') {
Expand Down
Loading