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:
parent
dc78c119b3
commit
dca3140065
60
api/bun.lock
60
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=="],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue