From dca31400653d05828399ecacf1174f4848764c02 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Feb 2026 21:30:28 -0800 Subject: [PATCH] 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 --- api/bun.lock | 60 ++++++++++ api/package.json | 4 +- api/src/db/queries.ts | 17 +++ api/src/db/schema.ts | 9 ++ api/src/index.ts | 10 +- api/src/middleware/siwe-auth.ts | 103 +++++++++++++++++ api/src/middleware/token-gate.ts | 93 +++++++++++++++ api/src/middleware/x402.ts | 109 ++++++++++++++++++ api/src/routes/auth.ts | 14 +++ api/src/routes/provision.ts | 15 +-- api/src/services/template-engine.ts | 16 +-- ...-2 - Multi-tenant-provisioning-platform.md | 4 +- spaces.yml | 4 +- 13 files changed, 429 insertions(+), 29 deletions(-) create mode 100644 api/src/middleware/siwe-auth.ts create mode 100644 api/src/middleware/token-gate.ts create mode 100644 api/src/middleware/x402.ts create mode 100644 api/src/routes/auth.ts diff --git a/api/bun.lock b/api/bun.lock index 1b2fc64..c663abd 100644 --- a/api/bun.lock +++ b/api/bun.lock @@ -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=="], } } diff --git a/api/package.json b/api/package.json index d367b82..65c48f2 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/src/db/queries.ts b/api/src/db/queries.ts index 507221e..34881ef 100644 --- a/api/src/db/queries.ts +++ b/api/src/db/queries.ts @@ -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): Instance { return { id: row.id as string, diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index 3d50db6..ff0f748 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -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; diff --git a/api/src/index.ts b/api/src/index.ts index ddfe1b5..6edd931 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -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)); diff --git a/api/src/middleware/siwe-auth.ts b/api/src/middleware/siwe-auth.ts new file mode 100644 index 0000000..4bc3987 --- /dev/null +++ b/api/src/middleware/siwe-auth.ts @@ -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 + * 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(); +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 "], + 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); + } +} diff --git a/api/src/middleware/token-gate.ts b/api/src/middleware/token-gate.ts new file mode 100644 index 0000000..bc45441 --- /dev/null +++ b/api/src/middleware/token-gate.ts @@ -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 { + 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(); +} diff --git a/api/src/middleware/x402.ts b/api/src/middleware/x402.ts new file mode 100644 index 0000000..aec6892 --- /dev/null +++ b/api/src/middleware/x402.ts @@ -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 +): 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); +} diff --git a/api/src/routes/auth.ts b/api/src/routes/auth.ts new file mode 100644 index 0000000..26953fd --- /dev/null +++ b/api/src/routes/auth.ts @@ -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; +} diff --git a/api/src/routes/provision.ts b/api/src/routes/provision.ts index b030571..b8718e2 100644 --- a/api/src/routes/provision.ts +++ b/api/src/routes/provision.ts @@ -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 & {}) { 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); diff --git a/api/src/services/template-engine.ts b/api/src/services/template-engine.ts index 38e5989..023e05a 100644 --- a/api/src/services/template-engine.ts +++ b/api/src/services/template-engine.ts @@ -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 = { diff --git a/backlog/tasks/task-2 - Multi-tenant-provisioning-platform.md b/backlog/tasks/task-2 - Multi-tenant-provisioning-platform.md index 52c56af..8b042ef 100644 --- a/backlog/tasks/task-2 - Multi-tenant-provisioning-platform.md +++ b/backlog/tasks/task-2 - Multi-tenant-provisioning-platform.md @@ -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 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. diff --git a/spaces.yml b/spaces.yml index d4bab62..dfeefc7 100644 --- a/spaces.yml +++ b/spaces.yml @@ -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