Skip to content

Commit 53325dd

Browse files
author
The No Hands Company
committed
feat: FEDERATED_STATIC_ONLY, federation blocklist, Indonesia-first i18n
FEDERATED_STATIC_ONLY=true (static-only node safety flag) - processManager.ts: startSiteProcess() throws 400 when STATIC_ONLY is set with clear error message directing user to a dynamic-capable node - sites.ts: POST /api/sites blocks nlpl/dynamic/node/python site types with STATIC_ONLY_NODE error code - federation.ts: capabilities array dynamically omits 'dynamic-hosting' and 'nlpl' when STATIC_ONLY=true — peers can see this in /.well-known/federation - resourceConfig.ts: FEDERATED_STATIC_ONLY exported, logs at startup - NlplPanel.tsx: amber warning banner when staticOnlyMode=true in runtimeInfo - nlpl.ts: staticOnlyMode field added to GET /nlpl/runtime-info response - .env.example: full documentation with what is/isn't affected Federation blocklist (defederation) - lib/db/src/schema/federationBlocks.ts: federation_blocks table (id, node_domain UNIQUE, reason, blocked_by, created_at, updated_at) - lib/db/src/schema/index.ts: federationBlocks exported - lib/db/migrations/0000_initial_schema.sql: federation_blocks table added - routes/federationBlocks.ts: full CRUD + enforcement GET /api/federation/blocks — list (admin only) POST /api/federation/blocks — add block (admin) + updates nodes.status DELETE /api/federation/blocks/:domain — remove block (admin) GET /api/federation/blocks/check?domain — public check endpoint for peers In-memory Set<string> for O(1) checks on every federation request loadBlocklist() called at startup, Set updated on every mutation - federation.ts: isBlocked() checked at top of ping and sync handlers — blocked nodes get 403 before any signature verification - index.ts: loadBlocklist() awaited in startup sequence Indonesia-first i18n (Southeast Asia language detection) - i18n/index.ts: getInitialLanguage() — deterministic language selection: 1. Stored localStorage preference (fh_language) 2. Browser navigator.language (id* → Bahasa, en* → English) 3. fh-node-region meta tag — ap-southeast* nodes → Bahasa Indonesia 4. Timezone detection — WIB/WITA/WIT → Bahasa Indonesia 5. English fallback Uses lng: getInitialLanguage() to set deterministically before LanguageDetector - index.html: <meta name='fh-node-region' content='%VITE_NODE_REGION%'> injected — replaced by Vite at build time from .env - .env.example: VITE_NODE_REGION documented (defaults to NODE_REGION)
1 parent 7a2714f commit 53325dd

File tree

15 files changed

+306
-3
lines changed

15 files changed

+306
-3
lines changed

.env.example

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,29 @@ DYNAMIC_MAX_RESTARTS=5
236236
# Global rate limit: 300 → 60 req/min
237237
# Compression: level 6 → level 1 (fastest)
238238
LOW_RESOURCE=false
239+
240+
# ── Static-only mode (federation safety) ─────────────────────────────────────
241+
# Set to "true" to disable dynamic site hosting (NLPL, Node.js, Python).
242+
# Recommended for:
243+
# - Volunteer nodes that only want to serve static sites
244+
# - Nodes in high-trust federation networks where dynamic execution is risky
245+
# - Nodes with very limited resources (no spare CPU for process management)
246+
#
247+
# When enabled:
248+
# - POST /api/sites with siteType nlpl/dynamic/node/python → 400 error
249+
# - POST /api/sites/:id/nlpl/start → 400 error with clear message
250+
# - Federation meta endpoint advertises capabilities: ["static"] (no "dynamic")
251+
# - Dashboard shows a banner on dynamic site pages
252+
#
253+
# Does NOT affect:
254+
# - Already-registered dynamic sites (they just can't be started)
255+
# - The build pipeline (git clone + npm build → static output is fine)
256+
# - Federation replication of static sites from other nodes
257+
FEDERATED_STATIC_ONLY=false
258+
259+
# ── Frontend node region hint (i18n) ─────────────────────────────────────────
260+
# Injected into index.html as <meta name="fh-node-region">.
261+
# Used by the frontend to default to Bahasa Indonesia for Southeast Asian nodes.
262+
# Set to your node's AWS-style region (same value as NODE_REGION).
263+
# Example: ap-southeast-3 (Jakarta) will default the UI to Bahasa Indonesia.
264+
VITE_NODE_REGION=${NODE_REGION:-}

artifacts/api-server/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { startAnalyticsFlusher, stopAnalyticsFlusher } from "./lib/analyticsFlus
2626
import { startGossipPusher, stopGossipPusher } from "./routes/gossip";
2727
import { getRedisClient, closeRedis } from "./lib/redis";
2828
import { startSyncRetryQueue, stopSyncRetryQueue } from "./lib/syncRetryQueue";
29+
import { loadBlocklist } from "./routes/federationBlocks";
2930
import { startAcmeRenewalScheduler, stopAcmeRenewalScheduler } from "./lib/acme";
3031
import { stopAllProcesses } from "./lib/processManager";
3132
import { startSiteHealthMonitor, stopSiteHealthMonitor } from "./lib/siteHealthMonitor";
@@ -128,6 +129,7 @@ ensureLocalNode()
128129
});
129130

130131
startHealthMonitor();
132+
await loadBlocklist();
131133
startAnalyticsFlusher();
132134
startGossipPusher();
133135
startSyncRetryQueue();

artifacts/api-server/src/lib/processManager.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,14 @@ export async function startSiteProcess(opts: {
267267
workDir: string;
268268
entryFile: string;
269269
}): Promise<{ port: number }> {
270+
// Respect FEDERATED_STATIC_ONLY — operator may disable dynamic hosting
271+
// for security/simplicity, especially important for volunteer nodes
272+
if (process.env.FEDERATED_STATIC_ONLY === "true") {
273+
throw new Error(
274+
"Dynamic site hosting is disabled on this node (FEDERATED_STATIC_ONLY=true). " +
275+
"This node only serves static sites. Contact the node operator to enable dynamic hosting.",
276+
);
277+
}
270278
const existing = processes.get(opts.siteId);
271279
if (existing && (existing.status === "running" || existing.status === "starting")) {
272280
return { port: existing.port };

artifacts/api-server/src/lib/resourceConfig.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,16 @@ export const GOSSIP_INTERVAL_MS = LOW_RESOURCE
8686
? parseInt(process.env.GOSSIP_INTERVAL_MS ?? "600000") // 10 minutes
8787
: parseInt(process.env.GOSSIP_INTERVAL_MS ?? "300000"); // 5 minutes
8888

89+
/** Whether dynamic site hosting (NLPL/Node/Python) is disabled on this node */
90+
export const FEDERATED_STATIC_ONLY = process.env.FEDERATED_STATIC_ONLY === "true";
91+
92+
if (FEDERATED_STATIC_ONLY) {
93+
console.warn(
94+
"[config] FEDERATED_STATIC_ONLY=true — dynamic site hosting disabled. " +
95+
"This node only serves static sites (HTML/CSS/JS)."
96+
);
97+
}
98+
8999
if (LOW_RESOURCE) {
90100
// Log once at startup so operators know the mode is active
91101
// Using console directly since the logger may not be initialised yet

artifacts/api-server/src/routes/federation.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { federationLimiter, writeLimiter } from "../middleware/rateLimiter";
88
import { parsePagination, buildPaginatedResponse } from "../lib/pagination";
99
import logger from "../lib/logger";
1010
import { resolveConflict } from "../lib/conflictResolution";
11+
import { isBlocked } from "./federationBlocks";
1112
import { federationSyncsTotal, federationPeersTotal } from "../lib/metrics";
1213

1314
const router: IRouter = Router();
@@ -27,7 +28,13 @@ router.get("/federation/meta", asyncHandler(async (_req, res) => {
2728
nodeCount: allNodes.length,
2829
activeSites: activeDeployments.length,
2930
joinedAt: localNode?.joinedAt ?? new Date().toISOString(),
30-
capabilities: ["site-hosting", "node-federation", "key-verification", "site-replication"],
31+
capabilities: [
32+
"site-hosting",
33+
"node-federation",
34+
"key-verification",
35+
"site-replication",
36+
...(process.env.FEDERATED_STATIC_ONLY === "true" ? [] : ["dynamic-hosting", "nlpl"]),
37+
],
3138
});
3239
}));
3340

@@ -38,6 +45,12 @@ router.post("/federation/ping", federationLimiter, asyncHandler(async (req, res)
3845
throw AppError.badRequest("Missing required fields: nodeDomain, challenge, signature");
3946
}
4047

48+
// Reject blocked nodes immediately
49+
if (isBlocked(nodeDomain)) {
50+
logger.warn({ nodeDomain }, "[federation] Ping rejected — node is on blocklist");
51+
throw AppError.forbidden("This node is not permitted to federate with us.");
52+
}
53+
4154
// Reject stale messages — prevents replay attacks
4255
if (timestamp) {
4356
const messageAge = Math.abs(Date.now() - parseInt(timestamp, 10));
@@ -205,6 +218,14 @@ router.post("/federation/sync", asyncHandler(async (req, res) => {
205218
throw AppError.badRequest("Missing siteDomain or deploymentId");
206219
}
207220

221+
// Reject blocked nodes
222+
const fromHeader = (req.body as { fromDomain?: string }).fromDomain ??
223+
req.headers["x-federation-from"] as string | undefined;
224+
if (fromHeader && isBlocked(fromHeader)) {
225+
logger.warn({ fromDomain: fromHeader, siteDomain }, "[federation] Sync rejected — node is on blocklist");
226+
throw AppError.forbidden("This node is not permitted to federate with us.");
227+
}
228+
208229
// Verify the signature on the sync message
209230
const signature = req.headers["x-federation-signature"] as string | undefined;
210231

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Federation blocklist routes.
3+
*
4+
* Operators can block specific peer nodes from federating with this node.
5+
* Blocked nodes cannot handshake, ping, sync, or appear in bootstrap.
6+
*
7+
* Routes:
8+
* GET /api/federation/blocks — list blocked nodes (admin)
9+
* POST /api/federation/blocks — add a block (admin)
10+
* DELETE /api/federation/blocks/:domain — remove a block (admin)
11+
* GET /api/federation/blocks/check — check if a domain is blocked (public — for peers)
12+
*/
13+
14+
import { Router, type IRouter, type Request, type Response } from "express";
15+
import { z } from "zod/v4";
16+
import { db, federationBlocksTable, nodesTable } from "@workspace/db";
17+
import { eq } from "drizzle-orm";
18+
import { asyncHandler, AppError } from "../lib/errors";
19+
import { requireAdmin } from "../middleware/requireAdmin";
20+
import { writeLimiter } from "../middleware/rateLimiter";
21+
import logger from "../lib/logger";
22+
23+
const router: IRouter = Router();
24+
25+
// In-memory Set for O(1) block checks on every incoming federation request.
26+
// Loaded at startup and kept in sync with DB mutations.
27+
export const blockedDomains = new Set<string>();
28+
29+
/** Load all blocked domains into the in-memory set at startup */
30+
export async function loadBlocklist(): Promise<void> {
31+
try {
32+
const rows = await db.select({ nodeDomain: federationBlocksTable.nodeDomain }).from(federationBlocksTable);
33+
blockedDomains.clear();
34+
for (const row of rows) blockedDomains.add(row.nodeDomain.toLowerCase());
35+
logger.info({ count: blockedDomains.size }, "[blocklist] Loaded federation blocklist");
36+
} catch (err) {
37+
logger.warn({ err }, "[blocklist] Failed to load blocklist — continuing without it");
38+
}
39+
}
40+
41+
/** Returns true if the given domain is on the blocklist */
42+
export function isBlocked(domain: string): boolean {
43+
return blockedDomains.has(domain.toLowerCase());
44+
}
45+
46+
// ── GET /api/federation/blocks ────────────────────────────────────────────────
47+
48+
router.get("/federation/blocks", requireAdmin, asyncHandler(async (req: Request, res: Response) => {
49+
const blocks = await db.select().from(federationBlocksTable).orderBy(federationBlocksTable.createdAt);
50+
res.json({ blocks, total: blocks.length });
51+
}));
52+
53+
// ── POST /api/federation/blocks ───────────────────────────────────────────────
54+
55+
router.post("/federation/blocks", requireAdmin, writeLimiter, asyncHandler(async (req: Request, res: Response) => {
56+
const { nodeDomain, reason } = z.object({
57+
nodeDomain: z.string().min(1).max(253).toLowerCase(),
58+
reason: z.string().max(500).optional(),
59+
}).parse(req.body);
60+
61+
// Check if already blocked
62+
const [existing] = await db.select({ id: federationBlocksTable.id }).from(federationBlocksTable)
63+
.where(eq(federationBlocksTable.nodeDomain, nodeDomain));
64+
if (existing) throw AppError.conflict(`${nodeDomain} is already blocked`);
65+
66+
const [block] = await db.insert(federationBlocksTable).values({
67+
nodeDomain,
68+
reason: reason ?? null,
69+
blockedBy: req.user?.id ?? null,
70+
}).returning();
71+
72+
// Update in-memory set immediately
73+
blockedDomains.add(nodeDomain);
74+
75+
// Mark the peer node as inactive if it exists in our nodes table
76+
await db.update(nodesTable)
77+
.set({ status: "inactive" })
78+
.where(eq(nodesTable.domain, nodeDomain));
79+
80+
logger.info({ nodeDomain, reason, blockedBy: req.user?.id }, "[blocklist] Node blocked");
81+
82+
res.status(201).json({ block, message: `${nodeDomain} is now blocked from federating with this node.` });
83+
}));
84+
85+
// ── DELETE /api/federation/blocks/:domain ────────────────────────────────────
86+
87+
router.delete("/federation/blocks/:domain", requireAdmin, writeLimiter, asyncHandler(async (req: Request, res: Response) => {
88+
const domain = (req.params.domain as string).toLowerCase();
89+
90+
const [deleted] = await db.delete(federationBlocksTable)
91+
.where(eq(federationBlocksTable.nodeDomain, domain))
92+
.returning();
93+
94+
if (!deleted) throw AppError.notFound(`No block found for ${domain}`);
95+
96+
blockedDomains.delete(domain);
97+
98+
logger.info({ domain, unblockedBy: req.user?.id }, "[blocklist] Node unblocked");
99+
100+
res.json({ message: `${domain} has been unblocked and can federate with this node again.` });
101+
}));
102+
103+
// ── GET /api/federation/blocks/check?domain= ─────────────────────────────────
104+
// Public endpoint — peers can check if they're blocked before attempting handshake.
105+
// Returns 200 with { blocked: true/false } rather than 403 so peers can handle it gracefully.
106+
107+
router.get("/federation/blocks/check", asyncHandler(async (req: Request, res: Response) => {
108+
const domain = ((req.query.domain as string) ?? "").toLowerCase();
109+
if (!domain) throw AppError.badRequest("domain query parameter required");
110+
111+
res.json({ domain, blocked: isBlocked(domain) });
112+
}));
113+
114+
export default router;

artifacts/api-server/src/routes/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import adminRouter from "./admin";
1616
import gossipRouter from "./gossip";
1717
import webhooksRouter from "./webhooks";
1818
import tlsRouter from "./tls";
19+
import federationBlocksRouter from "./federationBlocks";
1920
import redirectsRouter from "./redirects";
2021
import invitationsRouter from "./invitations";
2122
import formsRouter from "./forms";
@@ -47,6 +48,7 @@ router.use(domainsRouter);
4748
router.use(adminRouter);
4849
router.use(webhooksRouter);
4950
router.use(tlsRouter);
51+
router.use(federationBlocksRouter);
5052
router.use(redirectsRouter);
5153
router.use(invitationsRouter);
5254
router.use(formsRouter);

artifacts/api-server/src/routes/nlpl.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ router.get("/nlpl/runtime-info", asyncHandler(async (req: Request, res: Response
8888
nlplVersion,
8989
pythonVersion,
9090
pythonBin: PYTHON_BIN,
91+
staticOnlyMode: process.env.FEDERATED_STATIC_ONLY === "true",
9192
portRange: {
9293
start: parseInt(process.env.DYNAMIC_PORT_START ?? "9000"),
9394
end: parseInt(process.env.DYNAMIC_PORT_END ?? "9999"),

artifacts/api-server/src/routes/sites.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,22 @@ router.get("/sites", asyncHandler(async (req, res) => {
7171
}));
7272

7373
router.post("/sites", writeLimiter, asyncHandler(async (req, res) => {
74+
if (!req.isAuthenticated()) throw AppError.unauthorized();
75+
7476
const parsed = CreateSiteBody.safeParse(req.body);
7577
if (!parsed.success) throw AppError.badRequest(parsed.error.message);
7678

79+
// Enforce FEDERATED_STATIC_ONLY — only allow static/blog/portfolio site types
80+
const dynamicTypes = ["nlpl", "dynamic", "node", "python"];
81+
if (process.env.FEDERATED_STATIC_ONLY === "true" && dynamicTypes.includes(parsed.data.siteType ?? "")) {
82+
throw AppError.badRequest(
83+
`This node operates in static-only mode (FEDERATED_STATIC_ONLY=true). ` +
84+
`Dynamic site types (${dynamicTypes.join(", ")}) are not permitted. ` +
85+
`Create a static site or use a node that supports dynamic hosting.`,
86+
"STATIC_ONLY_NODE",
87+
);
88+
}
89+
7790
const [existing] = await db.select().from(sitesTable).where(eq(sitesTable.domain, parsed.data.domain));
7891
if (existing) throw AppError.conflict(`Domain '${parsed.data.domain}' is already registered`);
7992

artifacts/federated-hosting/index.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
66
<title>Federated Hosting</title>
77
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
8+
<!-- Node region — used by i18n to default to Bahasa for Indonesian nodes.
9+
The TypeScript server replaces this placeholder at runtime via the
10+
/api/health endpoint, or operators can set it at build time.
11+
Format: AWS-style region e.g. ap-southeast-3 (Jakarta), us-east-1 -->
12+
<meta name="fh-node-region" content="%VITE_NODE_REGION%" />
813
<link rel="preconnect" href="https://fonts.googleapis.com">
914
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
1015
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">

0 commit comments

Comments
 (0)