feat: add Phase 4 x402 metering and Phase 5 hardening

Phase 4: Usage metrics service (container stats, post count, storage),
billing calculator with pro-rated monthly pricing, usage + payment
routes, x402 middleware wired to provisioning endpoint.

Phase 5: In-memory rate limiter (general 10/min + provision 2/hour),
admin routes (force teardown, suspend/resume), Dockerfile hardened
with non-root user and healthcheck. Suspended instance status added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-24 21:45:55 -08:00
parent 908e2257e4
commit a536a9bc0f
9 changed files with 664 additions and 1 deletions

View File

@ -2,17 +2,28 @@ FROM oven/bun:1-alpine
WORKDIR /app
# Install deps as root
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile 2>/dev/null || bun install
COPY tsconfig.json ./
COPY src/ ./src/
RUN mkdir -p /data
# Create data dir and non-root user
RUN mkdir -p /data && \
addgroup -g 1001 rsocials && \
adduser -u 1001 -G rsocials -s /bin/sh -D rsocials && \
chown -R rsocials:rsocials /app /data
USER rsocials
ENV PORT=3001
ENV DATABASE_PATH=/data/instances.db
ENV NODE_ENV=production
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3001/health || exit 1
CMD ["bun", "run", "src/index.ts"]

View File

@ -5,10 +5,14 @@ import { initDb } from "./db/schema.js";
import { InstanceStore } from "./db/queries.js";
import { siweAuth } from "./middleware/siwe-auth.js";
import { tokenGate } from "./middleware/token-gate.js";
import { rateLimiter, provisionRateLimiter } from "./middleware/rate-limit.js";
import { setupX402FromEnv } from "./middleware/x402.js";
import { healthRoutes } from "./routes/health.js";
import { authRoutes } from "./routes/auth.js";
import { spacesRoutes } from "./routes/spaces.js";
import { provisionRoutes } from "./routes/provision.js";
import { usageRoutes } from "./routes/usage.js";
import { adminRoutes } from "./routes/admin.js";
const PORT = parseInt(process.env.PORT ?? "3001", 10);
const DB_PATH = process.env.DATABASE_PATH ?? "./data/instances.db";
@ -31,8 +35,30 @@ app.route("/v1/auth", authRoutes());
// Protected routes (SIWE wallet or API key)
app.use("/v1/spaces/*", siweAuth);
app.use("/v1/spaces/*", tokenGate);
app.use("/v1/spaces/*", rateLimiter);
// x402 payment gate on provisioning (only if X402_PAY_TO is set)
const x402Middleware = setupX402FromEnv();
if (x402Middleware) {
// Apply x402 only to POST (provisioning) and pay endpoint
app.use("/v1/spaces", async (c, next) => {
if (c.req.method === "POST") return x402Middleware(c, next);
return next();
});
}
// Provision rate limiter (stricter — 2/hour)
app.post("/v1/spaces", provisionRateLimiter);
// Space routes
app.route("/v1/spaces", provisionRoutes(store));
app.route("/v1/spaces", spacesRoutes(store));
app.route("/v1/spaces", usageRoutes(store));
// Admin routes (API key only)
app.use("/v1/admin/*", siweAuth); // reuse auth (checks API key first)
app.use("/v1/admin/*", rateLimiter);
app.route("/v1/admin", adminRoutes(store));
// 404 handler
app.notFound((c) => c.json({ error: "Not found" }, 404));

View File

@ -0,0 +1,138 @@
/**
* In-memory rate limiter per wallet/IP.
*
* Configurable:
* RATE_LIMIT_WINDOW_MS=60000 (1 minute default)
* RATE_LIMIT_MAX_REQUESTS=10 (10 requests per window)
* RATE_LIMIT_PROVISION_MAX=2 (2 provisions per hour)
*/
import type { Context, Next } from "hono";
interface RateEntry {
count: number;
resetAt: number;
}
const WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? "60000", 10);
const MAX_REQUESTS = parseInt(
process.env.RATE_LIMIT_MAX_REQUESTS ?? "10",
10
);
const PROVISION_MAX = parseInt(
process.env.RATE_LIMIT_PROVISION_MAX ?? "2",
10
);
const PROVISION_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const generalLimits = new Map<string, RateEntry>();
const provisionLimits = new Map<string, RateEntry>();
function cleanExpired(store: Map<string, RateEntry>) {
const now = Date.now();
for (const [key, entry] of store) {
if (now > entry.resetAt) store.delete(key);
}
}
function getIdentifier(c: Context): string {
const owner = c.get("owner" as never) as string | undefined;
if (owner && owner !== "admin") return owner;
return (
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
c.req.header("x-real-ip") ??
"unknown"
);
}
function checkLimit(
store: Map<string, RateEntry>,
key: string,
max: number,
windowMs: number
): { allowed: boolean; remaining: number; resetAt: number } {
const now = Date.now();
const entry = store.get(key);
if (!entry || now > entry.resetAt) {
const resetAt = now + windowMs;
store.set(key, { count: 1, resetAt });
return { allowed: true, remaining: max - 1, resetAt };
}
entry.count++;
if (entry.count > max) {
return { allowed: false, remaining: 0, resetAt: entry.resetAt };
}
return {
allowed: true,
remaining: max - entry.count,
resetAt: entry.resetAt,
};
}
/**
* General rate limiter for all API routes.
*/
export async function rateLimiter(c: Context, next: Next) {
const id = getIdentifier(c);
// Admin API key bypasses rate limiting
const authMethod = c.get("authMethod" as never) as string | undefined;
if (authMethod === "api-key") return next();
// Clean expired entries periodically
if (Math.random() < 0.1) cleanExpired(generalLimits);
const result = checkLimit(generalLimits, id, MAX_REQUESTS, WINDOW_MS);
c.header("X-RateLimit-Limit", String(MAX_REQUESTS));
c.header("X-RateLimit-Remaining", String(result.remaining));
c.header("X-RateLimit-Reset", String(Math.ceil(result.resetAt / 1000)));
if (!result.allowed) {
return c.json(
{
error: "Too many requests",
retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
},
429
);
}
return next();
}
/**
* Stricter rate limiter specifically for provisioning.
* 2 provisions per hour per wallet.
*/
export async function provisionRateLimiter(c: Context, next: Next) {
const id = getIdentifier(c);
const authMethod = c.get("authMethod" as never) as string | undefined;
if (authMethod === "api-key") return next();
if (Math.random() < 0.1) cleanExpired(provisionLimits);
const result = checkLimit(
provisionLimits,
id,
PROVISION_MAX,
PROVISION_WINDOW_MS
);
if (!result.allowed) {
return c.json(
{
error: "Provisioning rate limit exceeded",
detail: `Max ${PROVISION_MAX} provisions per hour`,
retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000),
},
429
);
}
return next();
}

141
api/src/routes/admin.ts Normal file
View File

@ -0,0 +1,141 @@
/**
* Admin-only routes for managing the provisioning platform.
*
* All routes require X-API-Key authentication (admin only).
*
* GET /v1/admin/instances List all instances (any owner)
* DELETE /v1/admin/instances/:slug Force teardown (bypass owner check)
* POST /v1/admin/instances/:slug/suspend Suspend an instance
* POST /v1/admin/instances/:slug/resume Resume a suspended instance
*/
import { Hono } from "hono";
import type { InstanceStore } from "../db/queries.js";
import {
teardownSpace,
checkContainerHealth,
} from "../services/docker-deployer.js";
import {
removeTunnelHostnames,
restartCloudflared,
} from "../services/tunnel-config.js";
export function adminRoutes(store: InstanceStore) {
const app = new Hono();
// Admin guard — only API key auth allowed
app.use("*", async (c, next) => {
const authMethod = c.get("authMethod" as never) as string;
if (authMethod !== "api-key") {
return c.json({ error: "Admin access required" }, 403);
}
return next();
});
// List all instances (all owners)
app.get("/instances", (c) => {
const instances = store.list();
const summary = {
total: instances.length,
active: instances.filter((i) => i.status === "active").length,
provisioning: instances.filter((i) => i.status === "provisioning").length,
failed: instances.filter((i) => i.status === "failed").length,
};
return c.json({ summary, instances });
});
// Force teardown (bypass owner check)
app.delete("/instances/:slug", async (c) => {
const slug = c.req.param("slug");
const instance = store.getBySlug(slug);
if (!instance) {
return c.json({ error: "Instance not found" }, 404);
}
if (instance.status === "destroyed") {
return c.json({ error: "Already destroyed" }, 400);
}
store.updateStatus(instance.id, "teardown");
store.addLog(instance.id, "admin_force_teardown");
// Run teardown in background
(async () => {
try {
removeTunnelHostnames(instance.primaryDomain, instance.fallbackDomain);
await restartCloudflared();
await teardownSpace(instance.slug);
store.updateStatus(instance.id, "destroyed");
store.addLog(instance.id, "teardown_complete");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
store.addLog(instance.id, "teardown_error", msg);
store.updateStatus(instance.id, "failed");
}
})();
return c.json({ message: "Force teardown started", instance });
});
// Suspend an instance (stop containers but keep data)
app.post("/instances/:slug/suspend", async (c) => {
const slug = c.req.param("slug");
const instance = store.getBySlug(slug);
if (!instance) {
return c.json({ error: "Instance not found" }, 404);
}
if (instance.status !== "active") {
return c.json({ error: `Cannot suspend: status is ${instance.status}` }, 400);
}
try {
const proc = Bun.spawn(
["docker", "compose", "-f", instance.composePath!, "stop"],
{ stdout: "pipe", stderr: "pipe" }
);
await proc.exited;
store.updateStatus(instance.id, "suspended" as never);
store.addLog(instance.id, "admin_suspend");
return c.json({ message: "Instance suspended", slug });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return c.json({ error: `Suspend failed: ${msg}` }, 500);
}
});
// Resume a suspended instance
app.post("/instances/:slug/resume", async (c) => {
const slug = c.req.param("slug");
const instance = store.getBySlug(slug);
if (!instance) {
return c.json({ error: "Instance not found" }, 404);
}
try {
const proc = Bun.spawn(
["docker", "compose", "-f", instance.composePath!, "start"],
{ stdout: "pipe", stderr: "pipe" }
);
await proc.exited;
const healthy = await checkContainerHealth(slug);
if (!healthy) {
store.addLog(instance.id, "resume_unhealthy");
return c.json({ error: "Containers started but unhealthy" }, 500);
}
store.updateStatus(instance.id, "active");
store.addLog(instance.id, "admin_resume");
return c.json({ message: "Instance resumed", slug });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return c.json({ error: `Resume failed: ${msg}` }, 500);
}
});
return app;
}

81
api/src/routes/usage.ts Normal file
View File

@ -0,0 +1,81 @@
/**
* Usage & billing routes.
*
* GET /v1/spaces/:slug/usage Container stats, post count, storage, billing estimate
* POST /v1/spaces/:slug/pay Settle balance via x402 payment
*/
import { Hono } from "hono";
import type { InstanceStore } from "../db/queries.js";
import { collectMetrics } from "../services/metrics.js";
import { calculateBill } from "../services/billing.js";
import { setupX402FromEnv } from "../middleware/x402.js";
export function usageRoutes(store: InstanceStore) {
const app = new Hono();
// Get usage + billing estimate for an instance
app.get("/:slug/usage", async (c) => {
const slug = c.req.param("slug");
const instance = store.getBySlug(slug);
if (!instance) {
return c.json({ error: "Instance not found" }, 404);
}
const owner = c.get("owner" as never) as string;
if (owner !== "admin" && instance.owner !== owner) {
return c.json({ error: "Not authorized" }, 403);
}
const metrics = await collectMetrics(slug, instance.status);
const billing = calculateBill(
slug,
metrics.postCount ?? 0,
metrics.storageMB ?? 0,
instance.createdAt
);
return c.json({ usage: metrics, billing });
});
// Settle balance — x402 payment endpoint
app.post("/:slug/pay", async (c) => {
const slug = c.req.param("slug");
const instance = store.getBySlug(slug);
if (!instance) {
return c.json({ error: "Instance not found" }, 404);
}
const owner = c.get("owner" as never) as string;
if (owner !== "admin" && instance.owner !== owner) {
return c.json({ error: "Not authorized" }, 403);
}
// If x402 is enabled, the middleware will have already verified payment
const payment = c.get("x402Payment" as never) as string | undefined;
if (payment) {
store.addLog(
instance.id,
"payment_received",
`x402 payment verified`
);
}
return c.json({
status: "paid",
instance: { slug: instance.slug, status: instance.status },
});
});
return app;
}
/**
* Wire x402 middleware to the payment endpoint (if configured).
*/
export function getX402PayMiddleware() {
return setupX402FromEnv({
description: "Monthly payment for Postiz space hosting",
});
}

View File

@ -0,0 +1,64 @@
/**
* Billing calculator for x402 metered usage.
*
* Pricing:
* Monthly base: $5.00 USDC (covers ~2.5GB RAM)
* Per post: $0.01 USDC
* Per GB storage: $0.50 USDC (above 1GB free tier)
*/
export interface BillingPeriod {
slug: string;
periodStart: string; // ISO date
periodEnd: string;
baseCostUSD: number;
postCount: number;
postCostUSD: number;
storageMB: number;
storageCostUSD: number;
totalUSD: number;
}
const BASE_MONTHLY_USD = parseFloat(
process.env.X402_BASE_MONTHLY_PRICE ?? "5.00"
);
const PER_POST_USD = parseFloat(process.env.X402_PER_POST_PRICE ?? "0.01");
const PER_GB_USD = parseFloat(process.env.X402_PER_GB_PRICE ?? "0.50");
const FREE_STORAGE_MB = 1024; // 1 GB free tier
export function calculateBill(
slug: string,
postCount: number,
storageMB: number,
createdAt: string
): BillingPeriod {
const now = new Date();
const created = new Date(createdAt);
// Pro-rate base cost if instance is less than a month old
const ageMs = now.getTime() - created.getTime();
const ageDays = ageMs / (1000 * 60 * 60 * 24);
const monthFraction = Math.min(ageDays / 30, 1);
const baseCostUSD = Math.round(BASE_MONTHLY_USD * monthFraction * 100) / 100;
const postCostUSD = Math.round(postCount * PER_POST_USD * 100) / 100;
const billableStorageMB = Math.max(0, storageMB - FREE_STORAGE_MB);
const billableStorageGB = billableStorageMB / 1024;
const storageCostUSD = Math.round(billableStorageGB * PER_GB_USD * 100) / 100;
const totalUSD =
Math.round((baseCostUSD + postCostUSD + storageCostUSD) * 100) / 100;
return {
slug,
periodStart: created.toISOString(),
periodEnd: now.toISOString(),
baseCostUSD,
postCount,
postCostUSD,
storageMB,
storageCostUSD,
totalUSD,
};
}

196
api/src/services/metrics.ts Normal file
View File

@ -0,0 +1,196 @@
/**
* Instance usage metrics queries Docker for container stats and
* the instance's PostgreSQL for post counts.
*/
export interface UsageMetrics {
slug: string;
status: string;
containerStats: ContainerStats | null;
postCount: number | null;
storageMB: number | null;
uptimeHours: number | null;
}
interface ContainerStats {
state: string;
memoryUsageMB: number;
cpuPercent: number;
}
/**
* Get container resource usage from Docker.
*/
export async function getContainerStats(
slug: string
): Promise<ContainerStats | null> {
try {
const proc = Bun.spawn(
[
"docker",
"stats",
`postiz-${slug}`,
"--no-stream",
"--format",
"{{.MemUsage}}\t{{.CPUPerc}}\t{{.Name}}",
],
{ stdout: "pipe", stderr: "pipe" }
);
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) return null;
const line = output.trim();
if (!line) return null;
const parts = line.split("\t");
const memStr = parts[0]?.split("/")[0]?.trim() ?? "0MiB";
const cpuStr = parts[1]?.replace("%", "").trim() ?? "0";
// Parse memory (could be MiB or GiB)
let memoryUsageMB = 0;
if (memStr.includes("GiB")) {
memoryUsageMB = parseFloat(memStr) * 1024;
} else {
memoryUsageMB = parseFloat(memStr);
}
return {
state: "running",
memoryUsageMB: Math.round(memoryUsageMB),
cpuPercent: parseFloat(cpuStr),
};
} catch {
return null;
}
}
/**
* Get post count from instance's PostgreSQL container.
*/
export async function getPostCount(slug: string): Promise<number | null> {
try {
const proc = Bun.spawn(
[
"docker",
"exec",
`postiz-${slug}-db`,
"psql",
"-U",
"postiz",
"-d",
"postiz-db",
"-t",
"-c",
"SELECT COUNT(*) FROM posts;",
],
{ stdout: "pipe", stderr: "pipe" }
);
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) return null;
return parseInt(output.trim(), 10) || 0;
} catch {
return null;
}
}
/**
* Get storage usage from Docker volumes.
*/
export async function getStorageMB(slug: string): Promise<number | null> {
try {
const proc = Bun.spawn(
[
"docker",
"system",
"df",
"-v",
"--format",
"{{.Name}}\t{{.Size}}",
],
{ stdout: "pipe", stderr: "pipe" }
);
const output = await new Response(proc.stdout).text();
await proc.exited;
// Sum volumes matching the slug
let totalMB = 0;
for (const line of output.split("\n")) {
if (line.includes(slug)) {
const sizeStr = line.split("\t")[1] ?? "0B";
if (sizeStr.includes("GB")) {
totalMB += parseFloat(sizeStr) * 1024;
} else if (sizeStr.includes("MB")) {
totalMB += parseFloat(sizeStr);
} else if (sizeStr.includes("kB")) {
totalMB += parseFloat(sizeStr) / 1024;
}
}
}
return Math.round(totalMB);
} catch {
return null;
}
}
/**
* Get container uptime in hours.
*/
export async function getUptimeHours(slug: string): Promise<number | null> {
try {
const proc = Bun.spawn(
[
"docker",
"inspect",
`postiz-${slug}`,
"--format",
"{{.State.StartedAt}}",
],
{ stdout: "pipe", stderr: "pipe" }
);
const output = await new Response(proc.stdout).text();
await proc.exited;
if (proc.exitCode !== 0) return null;
const startedAt = new Date(output.trim());
const now = new Date();
const hours = (now.getTime() - startedAt.getTime()) / (1000 * 60 * 60);
return Math.round(hours * 10) / 10;
} catch {
return null;
}
}
/**
* Collect all usage metrics for an instance.
*/
export async function collectMetrics(
slug: string,
status: string
): Promise<UsageMetrics> {
if (status !== "active") {
return {
slug,
status,
containerStats: null,
postCount: null,
storageMB: null,
uptimeHours: null,
};
}
const [containerStats, postCount, storageMB, uptimeHours] =
await Promise.all([
getContainerStats(slug),
getPostCount(slug),
getStorageMB(slug),
getUptimeHours(slug),
]);
return { slug, status, containerStats, postCount, storageMB, uptimeHours };
}

View File

@ -27,6 +27,7 @@ export interface Instance {
export type InstanceStatus =
| "provisioning"
| "active"
| "suspended"
| "failed"
| "teardown"
| "destroyed";

View File

@ -44,6 +44,11 @@ const statusConfig: Record<
color: "bg-destructive/10 text-destructive",
icon: <AlertCircle className="h-3 w-3" />,
},
suspended: {
label: "Suspended",
color: "bg-blue-500/10 text-blue-600",
icon: <Clock className="h-3 w-3" />,
},
destroying: {
label: "Removing",
color: "bg-orange-500/10 text-orange-600",