feat: remove Sablier + add Phase 2 SIWE auth and CRDT token gating

Sablier removal:
- Postiz needs Temporal running 24/7 for scheduled posts, so Sablier
  auto-sleep is incompatible. Default changed to sablier: false.
- Template engine and provisioning route updated accordingly.

Phase 2 - Authentication & Token Gating:
- SIWE (Sign-In with Ethereum) wallet auth via siwe + viem
- Nonce endpoint at GET /v1/auth/nonce
- Dual auth: API key (admin) or SIWE Bearer token (wallet users)
- CRDT token gate checks balance via rSpace internal API
- Token burn tracking in SQLite (token_burns table)
- x402 payment middleware ported from rspace-online (Phase 4 ready)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-24 21:30:28 -08:00
parent dc78c119b3
commit dca3140065
13 changed files with 429 additions and 29 deletions

View File

@ -8,6 +8,8 @@
"hono": "^4.7.0",
"js-yaml": "^4.1.0",
"nanoid": "^5.1.0",
"siwe": "^3.0.0",
"viem": "^2.46.3",
},
"devDependencies": {
"@types/bun": "^1.3.9",
@ -17,24 +19,82 @@
},
},
"packages": {
"@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.10.1", "", {}, "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw=="],
"@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
"@noble/curves": ["@noble/curves@1.9.1", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA=="],
"@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="],
"@scure/base": ["@scure/base@1.2.6", "", {}, "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg=="],
"@scure/bip32": ["@scure/bip32@1.7.0", "", { "dependencies": { "@noble/curves": "~1.9.0", "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw=="],
"@scure/bip39": ["@scure/bip39@1.6.0", "", { "dependencies": { "@noble/hashes": "~1.8.0", "@scure/base": "~1.2.5" } }, "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A=="],
"@spruceid/siwe-parser": ["@spruceid/siwe-parser@3.0.0", "", { "dependencies": { "@noble/hashes": "^1.1.2", "apg-js": "^4.4.0" } }, "sha512-Y92k63ilw/8jH9Ry4G2e7lQd0jZAvb0d/Q7ssSD0D9mp/Zt2aCXIc3g0ny9yhplpAx1QXHsMz/JJptHK/zDGdw=="],
"@stablelib/binary": ["@stablelib/binary@1.0.1", "", { "dependencies": { "@stablelib/int": "^1.0.1" } }, "sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q=="],
"@stablelib/int": ["@stablelib/int@1.0.1", "", {}, "sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w=="],
"@stablelib/random": ["@stablelib/random@1.0.2", "", { "dependencies": { "@stablelib/binary": "^1.0.1", "@stablelib/wipe": "^1.0.1" } }, "sha512-rIsE83Xpb7clHPVRlBj8qNe5L8ISQOzjghYQm/dZ7VaM2KHYwMW5adjQjrzTZCchFnNCNhkwtnOBa9HTMJCI8w=="],
"@stablelib/wipe": ["@stablelib/wipe@1.0.1", "", {}, "sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
"@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
"abitype": ["abitype@1.2.3", "", { "peerDependencies": { "typescript": ">=5.0.4", "zod": "^3.22.0 || ^4.0.0" }, "optionalPeers": ["typescript", "zod"] }, "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg=="],
"aes-js": ["aes-js@4.0.0-beta.5", "", {}, "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q=="],
"apg-js": ["apg-js@4.4.0", "", {}, "sha512-fefmXFknJmtgtNEXfPwZKYkMFX4Fyeyz+fNF6JWp87biGOPslJbCBVU158zvKRZfHBKnJDy8CMM40oLFGkXT8Q=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"ethers": ["ethers@6.16.0", "", { "dependencies": { "@adraffy/ens-normalize": "1.10.1", "@noble/curves": "1.2.0", "@noble/hashes": "1.3.2", "@types/node": "22.7.5", "aes-js": "4.0.0-beta.5", "tslib": "2.7.0", "ws": "8.17.1" } }, "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"hono": ["hono@4.12.2", "", {}, "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg=="],
"isows": ["isows@1.0.7", "", { "peerDependencies": { "ws": "*" } }, "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
"ox": ["ox@0.12.4", "", { "dependencies": { "@adraffy/ens-normalize": "^1.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "1.9.1", "@noble/hashes": "^1.8.0", "@scure/bip32": "^1.7.0", "@scure/bip39": "^1.6.0", "abitype": "^1.2.3", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-+P+C7QzuwPV8lu79dOwjBKfB2CbnbEXe/hfyyrff1drrO1nOOj3Hc87svHfcW1yneRr3WXaKr6nz11nq+/DF9Q=="],
"siwe": ["siwe@3.0.0", "", { "dependencies": { "@spruceid/siwe-parser": "^3.0.0", "@stablelib/random": "^1.0.1" }, "peerDependencies": { "ethers": "^5.6.8 || ^6.0.8" } }, "sha512-P2/ry7dHYJA6JJ5+veS//Gn2XDwNb3JMvuD6xiXX8L/PJ1SNVD4a3a8xqEbmANx+7kNQcD8YAh1B9bNKKvRy/g=="],
"tslib": ["tslib@2.7.0", "", {}, "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"viem": ["viem@2.46.3", "", { "dependencies": { "@noble/curves": "1.9.1", "@noble/hashes": "1.8.0", "@scure/bip32": "1.7.0", "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", "ox": "0.12.4", "ws": "8.18.3" }, "peerDependencies": { "typescript": ">=5.0.4" }, "optionalPeers": ["typescript"] }, "sha512-2LJS+Hyh2sYjHXQtzfv1kU9pZx9dxFzvoU/ZKIcn0FNtOU0HQuIICuYdWtUDFHaGXbAdVo8J1eCvmjkL9JVGwg=="],
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"ethers/@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="],
"ethers/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="],
"ethers/@types/node": ["@types/node@22.7.5", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ=="],
"ethers/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="],
"ox/@adraffy/ens-normalize": ["@adraffy/ens-normalize@1.11.1", "", {}, "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ=="],
"ethers/@types/node/undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="],
}
}

View File

@ -10,7 +10,9 @@
"dependencies": {
"hono": "^4.7.0",
"js-yaml": "^4.1.0",
"nanoid": "^5.1.0"
"nanoid": "^5.1.0",
"siwe": "^3.0.0",
"viem": "^2.46.3"
},
"devDependencies": {
"@types/bun": "^1.3.9",

View File

@ -109,6 +109,23 @@ export class InstanceStore {
}));
}
recordTokenBurn(instanceId: string, wallet: string, amount: number): void {
this.db
.prepare(
"INSERT INTO token_burns (instance_id, wallet, amount) VALUES (?, ?, ?)"
)
.run(instanceId, wallet, amount);
}
getTotalBurned(wallet: string): number {
const row = this.db
.prepare(
"SELECT COALESCE(SUM(amount), 0) as total FROM token_burns WHERE wallet = ?"
)
.get(wallet) as { total: number };
return row.total;
}
private rowToInstance(row: Record<string, unknown>): Instance {
return {
id: row.id as string,

View File

@ -27,9 +27,18 @@ export function initDb(path: string): Database {
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS token_burns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_id TEXT NOT NULL REFERENCES instances(id),
wallet TEXT NOT NULL,
amount INTEGER NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_instances_slug ON instances(slug);
CREATE INDEX IF NOT EXISTS idx_instances_owner ON instances(owner);
CREATE INDEX IF NOT EXISTS idx_provision_log_instance ON provision_log(instance_id);
CREATE INDEX IF NOT EXISTS idx_token_burns_wallet ON token_burns(wallet);
`);
return db;

View File

@ -3,8 +3,10 @@ import { logger } from "hono/logger";
import { cors } from "hono/cors";
import { initDb } from "./db/schema.js";
import { InstanceStore } from "./db/queries.js";
import { apiKeyAuth } from "./middleware/auth.js";
import { siweAuth } from "./middleware/siwe-auth.js";
import { tokenGate } from "./middleware/token-gate.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";
@ -24,9 +26,11 @@ app.use("*", cors());
// Public routes
app.route("/health", healthRoutes(store));
app.route("/v1/auth", authRoutes());
// Protected routes
app.use("/v1/*", apiKeyAuth);
// Protected routes (SIWE wallet or API key)
app.use("/v1/spaces/*", siweAuth);
app.use("/v1/spaces/*", tokenGate);
app.route("/v1/spaces", provisionRoutes(store));
app.route("/v1/spaces", spacesRoutes(store));

View File

@ -0,0 +1,103 @@
/**
* SIWE (Sign-In with Ethereum) authentication middleware.
*
* Flow:
* 1. Client calls GET /v1/auth/nonce to get a challenge nonce
* 2. Client signs a SIWE message with their wallet
* 3. Client sends the signed message in the Authorization header:
* Authorization: Bearer <base64-encoded JSON { message, signature }>
* 4. Server verifies signature, extracts wallet address as owner identity
*
* Admin API key auth still works as a fallback.
*/
import type { Context, Next } from "hono";
import { SiweMessage } from "siwe";
const ADMIN_API_KEY = process.env.ADMIN_API_KEY;
const EXPECTED_DOMAIN = process.env.SIWE_DOMAIN ?? "rsocials.online";
// In-memory nonce store (TTL 5 minutes)
const nonceStore = new Map<string, number>();
const NONCE_TTL_MS = 5 * 60 * 1000;
export function generateNonce(): string {
const nonce = crypto.randomUUID().replace(/-/g, "");
nonceStore.set(nonce, Date.now());
// Clean expired nonces
const now = Date.now();
for (const [key, ts] of nonceStore) {
if (now - ts > NONCE_TTL_MS) nonceStore.delete(key);
}
return nonce;
}
export async function siweAuth(c: Context, next: Next) {
// Try API key first (admin override)
const apiKey = c.req.header("X-API-Key");
if (apiKey && ADMIN_API_KEY && apiKey === ADMIN_API_KEY) {
c.set("owner" as never, "admin");
c.set("authMethod" as never, "api-key");
return next();
}
// Try SIWE Bearer token
const authHeader = c.req.header("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return c.json(
{
error: "Authentication required",
methods: ["X-API-Key header", "Authorization: Bearer <siwe-token>"],
nonceEndpoint: "GET /v1/auth/nonce",
},
401
);
}
const token = authHeader.slice(7);
try {
const decoded = JSON.parse(
Buffer.from(token, "base64").toString("utf-8")
) as { message: string; signature: string };
const siweMessage = new SiweMessage(decoded.message);
// Verify the nonce is one we issued
const nonceTs = nonceStore.get(siweMessage.nonce);
if (!nonceTs) {
return c.json({ error: "Invalid or expired nonce" }, 401);
}
if (Date.now() - nonceTs > NONCE_TTL_MS) {
nonceStore.delete(siweMessage.nonce);
return c.json({ error: "Nonce expired" }, 401);
}
// Verify signature
const result = await siweMessage.verify({
signature: decoded.signature as `0x${string}`,
domain: EXPECTED_DOMAIN,
});
if (!result.success) {
return c.json(
{ error: "SIWE verification failed", details: result.error?.type },
401
);
}
// Consume nonce (one-time use)
nonceStore.delete(siweMessage.nonce);
// Set wallet address as owner
c.set("owner" as never, siweMessage.address.toLowerCase());
c.set("authMethod" as never, "siwe");
return next();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return c.json({ error: "Invalid SIWE token", details: msg }, 401);
}
}

View File

@ -0,0 +1,93 @@
/**
* CRDT Token Gate middleware.
*
* Checks that the authenticated wallet holds sufficient CRDT tokens
* before allowing provisioning. Queries the rSpace internal API for
* token balances.
*
* Configurable via environment:
* CRDT_GATE_ENABLED=true (default: false)
* CRDT_REQUIRED_BALANCE=100 (tokens required to provision)
* CRDT_RSPACE_API_URL=http://... (rSpace API endpoint)
* CRDT_RSPACE_API_KEY=... (internal API key)
*/
import type { Context, Next } from "hono";
const GATE_ENABLED = process.env.CRDT_GATE_ENABLED === "true";
const REQUIRED_BALANCE = parseInt(
process.env.CRDT_REQUIRED_BALANCE ?? "100",
10
);
const RSPACE_API_URL =
process.env.CRDT_RSPACE_API_URL ?? "http://rspace-online:3000";
const RSPACE_API_KEY = process.env.CRDT_RSPACE_API_KEY ?? "";
export interface TokenBalance {
holder: string;
balance: number;
token: string;
}
async function getTokenBalance(walletAddress: string): Promise<number> {
try {
const res = await fetch(
`${RSPACE_API_URL}/api/tokens/balance/${walletAddress}`,
{
headers: {
Authorization: `Bearer ${RSPACE_API_KEY}`,
"Content-Type": "application/json",
},
}
);
if (!res.ok) {
console.error(
`[token-gate] rSpace API error: ${res.status} ${await res.text()}`
);
return 0;
}
const data = (await res.json()) as TokenBalance;
return data.balance ?? 0;
} catch (err) {
console.error("[token-gate] Failed to check balance:", err);
return 0;
}
}
export async function tokenGate(c: Context, next: Next) {
if (!GATE_ENABLED) {
return next();
}
const owner = c.get("owner" as never) as string;
const authMethod = c.get("authMethod" as never) as string;
// Admin API key bypasses token gate
if (authMethod === "api-key") {
return next();
}
// Wallet auth — check CRDT balance
if (!owner || owner === "admin") {
return c.json({ error: "Wallet authentication required for token gate" }, 401);
}
const balance = await getTokenBalance(owner);
if (balance < REQUIRED_BALANCE) {
return c.json(
{
error: "Insufficient CRDT token balance",
required: REQUIRED_BALANCE,
current: balance,
holder: owner,
},
403
);
}
c.set("tokenBalance" as never, balance);
return next();
}

109
api/src/middleware/x402.ts Normal file
View File

@ -0,0 +1,109 @@
/**
* x402 Hono middleware payment gate for rSocials provisioning.
* Ported from rspace-online/shared/x402/hono-middleware.ts
*
* When X402_PAY_TO env is set, protects routes with x402 micro-transactions.
* When not set, acts as a no-op passthrough.
*
* Phase 4: Will be applied to POST /v1/spaces for paid provisioning.
*/
import type { Context, Next, MiddlewareHandler } from "hono";
export interface X402Config {
payTo: string;
network: string;
amount: string;
facilitatorUrl: string;
resource?: string;
description?: string;
}
export function createX402Middleware(config: X402Config): MiddlewareHandler {
return async (c: Context, next: Next) => {
const paymentHeader = c.req.header("X-PAYMENT");
if (!paymentHeader) {
const requirements = {
x402Version: 1,
scheme: "exact",
network: config.network,
maxAmountRequired: config.amount,
resource: config.resource || c.req.url,
description:
config.description || "Payment required to provision a space",
payTo: config.payTo,
maxTimeoutSeconds: 300,
};
return c.json(
{ error: "Payment Required", paymentRequirements: requirements },
402,
{ "X-PAYMENT-REQUIREMENTS": JSON.stringify(requirements) }
);
}
try {
const verifyRes = await fetch(`${config.facilitatorUrl}/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
payment: paymentHeader,
requirements: {
scheme: "exact",
network: config.network,
maxAmountRequired: config.amount,
payTo: config.payTo,
},
}),
});
if (!verifyRes.ok) {
const err = await verifyRes.text();
return c.json(
{ error: "Payment verification failed", details: err },
402
);
}
const result = (await verifyRes.json()) as { valid?: boolean };
if (!result.valid) {
return c.json({ error: "Payment invalid or insufficient" }, 402);
}
c.set("x402Payment" as never, paymentHeader);
await next();
} catch (e) {
console.error("[x402] Verification error:", e);
return c.json(
{ error: "Payment verification service unavailable" },
503
);
}
};
}
export function setupX402FromEnv(
overrides?: Partial<X402Config>
): MiddlewareHandler | null {
const payTo = process.env.X402_PAY_TO;
if (!payTo) {
console.log("[x402] Disabled — X402_PAY_TO not set");
return null;
}
const config: X402Config = {
payTo,
network: process.env.X402_NETWORK || "eip155:84532",
amount: process.env.X402_PROVISION_PRICE || "5.00",
facilitatorUrl:
process.env.X402_FACILITATOR_URL || "https://x402.org/facilitator",
description: "Payment required to provision a Postiz space",
...overrides,
};
console.log(
`[x402] Enabled — payTo=${payTo}, network=${config.network}, amount=${config.amount}`
);
return createX402Middleware(config);
}

14
api/src/routes/auth.ts Normal file
View File

@ -0,0 +1,14 @@
import { Hono } from "hono";
import { generateNonce } from "../middleware/siwe-auth.js";
export function authRoutes() {
const app = new Hono();
// Get a nonce for SIWE authentication
app.get("/nonce", (c) => {
const nonce = generateNonce();
return c.json({ nonce });
});
return app;
}

View File

@ -4,7 +4,7 @@ import type { InstanceStore } from "../db/queries.js";
import type { ProvisionRequest, SpaceConfig } from "../types.js";
import { checkResources } from "../services/resource-monitor.js";
import { deploySpace, teardownSpace, checkContainerHealth } from "../services/docker-deployer.js";
import { addSablierEntry, removeSablierEntry } from "../services/sablier-config.js";
// Sablier removed — Postiz needs Temporal running 24/7 for scheduled posts
import { addTunnelHostnames, removeTunnelHostnames, restartCloudflared } from "../services/tunnel-config.js";
const SLUG_RE = /^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/;
@ -140,18 +140,13 @@ async function runProvisioning(
}
store.addLog(instanceId, "health_ok", "Container is running");
// 9. Update Sablier config
store.addLog(instanceId, "sablier_config", "Adding Sablier routing");
addSablierEntry(config.slug, config.displayName, config.primaryDomain, config.fallbackDomain);
store.addLog(instanceId, "sablier_ok");
// 10. Update tunnel config + restart
// 9. Update tunnel config + restart
store.addLog(instanceId, "tunnel_config", "Adding tunnel hostnames");
addTunnelHostnames(config.primaryDomain, config.fallbackDomain);
await restartCloudflared();
store.addLog(instanceId, "tunnel_ok");
// 11. Mark active
// 10. Mark active
store.updateStatus(instanceId, "active");
store.addLog(instanceId, "provision_complete", `Live at https://${config.primaryDomain}`);
} catch (err) {
@ -163,10 +158,6 @@ async function runProvisioning(
async function runTeardown(store: InstanceStore, instance: ReturnType<InstanceStore["getBySlug"]> & {}) {
try {
// Remove Sablier config
store.addLog(instance.id, "sablier_remove");
removeSablierEntry(instance.slug);
// Remove tunnel hostnames
store.addLog(instance.id, "tunnel_remove");
removeTunnelHostnames(instance.primaryDomain, instance.fallbackDomain);

View File

@ -22,19 +22,15 @@ export function generateComposeFile(config: SpaceConfig): string {
POSTIZ_OAUTH_SCOPE: 'openid profile email'
# POSTIZ_OAUTH_CLIENT_ID + CLIENT_SECRET from Infisical`;
// Build Sablier labels
const traefikLabels = ` - "traefik.enable=false"
- "sablier.enable=true"
- "sablier.group=postiz-${config.slug}"
// Traefik labels (always-on, no Sablier — Temporal needs to run for scheduled posts)
const traefikLabels = ` - "traefik.enable=true"
- "traefik.http.routers.postiz-${config.slug}.rule=Host(\`${config.primaryDomain}\`) || Host(\`${config.fallbackDomain}\`)"
- "traefik.http.routers.postiz-${config.slug}.entrypoints=web,websecure"
- "traefik.http.services.postiz-${config.slug}.loadbalancer.server.port=5000"`;
- "traefik.http.services.postiz-${config.slug}.loadbalancer.server.port=5000"
- "traefik.docker.network=traefik-public"`;
const sablierDb = ` labels:
- "sablier.enable=true"
- "sablier.group=postiz-${config.slug}"`;
const sablierRedis = sablierDb;
const sablierDb = "";
const sablierRedis = "";
// Substitutions
const replacements: Record<string, string> = {

View File

@ -4,7 +4,7 @@ title: Multi-tenant provisioning platform
status: In Progress
assignee: []
created_date: '2026-02-24 03:54'
updated_date: '2026-02-25 05:12'
updated_date: '2026-02-25 05:16'
labels: []
dependencies: []
priority: medium
@ -20,4 +20,6 @@ Self-service API for communities to provision their own Postiz instance at <spac
<!-- SECTION:NOTES:BEGIN -->
Starting Phase 1: Provisioning API Core (Hono/Bun)
Phase 1 complete: Provisioning API scaffold with all core services. Compiles clean, starts on port 3001. Next: Phase 2 (CRDT token gating) or deploy Phase 1 to Netcup for testing.
<!-- SECTION:NOTES:END -->

View File

@ -33,8 +33,8 @@ defaults:
disable_registration: false
is_general: true
api_limit: 30
# Sablier auto-sleep (saves resources for low-traffic spaces)
sablier: true
# Sablier disabled — Postiz needs Temporal running 24/7 for scheduled posts
sablier: false
# Pocket ID OAuth (enabled by default, per-space client_id/secret in Infisical)
oauth: true
oauth_url: https://auth.jeffemmett.com