4bnode 4.1.2 → 4.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/add-security.js +0 -18
- package/app-1/.4bnode/.passkey +1 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
- package/app-1/.4bnode/assets/images/mongo.png +0 -0
- package/app-1/.4bnode/assets/js/App.js +139 -0
- package/app-1/.4bnode/assets/js/Root.js +40 -0
- package/app-1/.4bnode/assets/js/components/Modal.js +52 -0
- package/app-1/.4bnode/assets/js/components/Sidebar.js +73 -0
- package/app-1/.4bnode/assets/js/components/ToastContainer.js +15 -0
- package/app-1/.4bnode/assets/js/context.js +7 -0
- package/app-1/.4bnode/assets/js/lib.js +113 -0
- package/app-1/.4bnode/assets/js/main.js +5 -0
- package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
- package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +1879 -0
- package/app-1/.4bnode/assets/js/pages/DashboardPage.js +435 -0
- package/app-1/.4bnode/assets/js/pages/DatabasePage.js +300 -0
- package/app-1/.4bnode/assets/js/pages/DocsPage.js +150 -0
- package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +103 -0
- package/app-1/.4bnode/assets/js/pages/LogsPage.js +205 -0
- package/app-1/.4bnode/assets/js/pages/MailPage.js +134 -0
- package/app-1/.4bnode/assets/js/pages/RealtimePage.js +186 -0
- package/app-1/.4bnode/assets/js/pages/RoutesPage.js +1436 -0
- package/app-1/.4bnode/assets/js/pages/SchemasPage.js +838 -0
- package/app-1/.4bnode/assets/js/pages/SecurityPage.js +74 -0
- package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +170 -0
- package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +105 -0
- package/app-1/.4bnode/codegen.js +1249 -0
- package/app-1/.4bnode/dev-api.js +3621 -0
- package/app-1/.4bnode/ui.html +1578 -0
- package/app-1/README.md +47 -0
- package/app-1/ecosystem.config.cjs +25 -0
- package/app-1/index.js +145 -0
- package/app-1/package.json +35 -0
- package/app-1/public/index.html +292 -0
- package/app-1/src/db.js +13 -0
- package/app-1/src/docs.js +84 -0
- package/app-1/src/middleware/errorHandler.js +15 -0
- package/app-1/src/middleware/roles.js +14 -0
- package/app-1/src/middleware/validate.js +20 -0
- package/app-1/src/models/login.js +9 -0
- package/app-1/src/openapi.json +62 -0
- package/app-1/src/routes/products.js +26 -0
- package/app-1/src/validators/products.js +7 -0
- package/app-1/src/validators/user.js +7 -0
- package/index.js +2 -17
- package/lib/codegen.js +91 -44
- package/lib/codegen.test.js +23 -3
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/App.js +0 -2
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -3
- package/skeleton/.4bnode/assets/js/lib.js +0 -3
- package/skeleton/.4bnode/assets/js/pages/DocsPage.js +29 -21
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +12 -0
- package/skeleton/.4bnode/codegen.js +91 -44
- package/skeleton/.4bnode/dev-api.js +173 -87
- package/skeleton/_gitignore +0 -1
- package/skeleton/index.js +4 -8
- package/skeleton/.4bnode/assets/js/pages/ApiKeysPage.js +0 -164
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
generateMongooseModel,
|
|
23
23
|
buildZodSchemaFile,
|
|
24
24
|
wireValidateIntoRouteContent,
|
|
25
|
+
buildAuthMiddleware,
|
|
26
|
+
wireAuthIntoRouteContent,
|
|
25
27
|
addImportAfterLastImport,
|
|
26
28
|
insertBeforeListenContent,
|
|
27
29
|
AI_PLAN_SCHEMA,
|
|
@@ -1184,7 +1186,6 @@ router.get('/api/status', (req, res) => {
|
|
|
1184
1186
|
rateLimit: indexContent.includes("from 'express-rate-limit'") || indexContent.includes('rateLimit('),
|
|
1185
1187
|
errorHandler: mwExists('errorHandler.js'),
|
|
1186
1188
|
roles: mwExists('roles.js'),
|
|
1187
|
-
apiKey: mwExists('apiKey.js'),
|
|
1188
1189
|
validation: mwExists('validate.js'),
|
|
1189
1190
|
};
|
|
1190
1191
|
|
|
@@ -1203,11 +1204,8 @@ router.get('/api/status', (req, res) => {
|
|
|
1203
1204
|
serialportConfigured,
|
|
1204
1205
|
security,
|
|
1205
1206
|
aiConfigured: !!getActiveAiKey(),
|
|
1206
|
-
jobsConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'jobs', 'scheduler.js')),
|
|
1207
1207
|
docsConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'docs.js')),
|
|
1208
|
-
|
|
1209
|
-
mailConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'services', 'mailer.js')),
|
|
1210
|
-
testsConfigured: fs.existsSync(path.join(process.cwd(), 'vitest.config.js'))
|
|
1208
|
+
mailConfigured: fs.existsSync(path.join(process.cwd(), 'src', 'services', 'mailer.js'))
|
|
1211
1209
|
});
|
|
1212
1210
|
} catch (err) {
|
|
1213
1211
|
res.status(500).json({ error: err.message });
|
|
@@ -1215,38 +1213,8 @@ router.get('/api/status', (req, res) => {
|
|
|
1215
1213
|
});
|
|
1216
1214
|
|
|
1217
1215
|
// ---------------------------------------------------------------------------
|
|
1218
|
-
//
|
|
1219
|
-
// ships with the app (unlike .4bnode), and the apiKey middleware reads it live,
|
|
1220
|
-
// so generate/revoke take effect without a restart. Writing a .json file is not
|
|
1221
|
-
// watched by nodemon, so no restart is triggered here either.
|
|
1216
|
+
// Security configuration
|
|
1222
1217
|
// ---------------------------------------------------------------------------
|
|
1223
|
-
function apiKeysFilePath() {
|
|
1224
|
-
return path.join(process.cwd(), 'src', 'api-keys.json');
|
|
1225
|
-
}
|
|
1226
|
-
function readApiKeys() {
|
|
1227
|
-
try {
|
|
1228
|
-
const p = apiKeysFilePath();
|
|
1229
|
-
if (fs.existsSync(p)) {
|
|
1230
|
-
const list = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
1231
|
-
if (Array.isArray(list)) return list;
|
|
1232
|
-
}
|
|
1233
|
-
} catch {}
|
|
1234
|
-
return [];
|
|
1235
|
-
}
|
|
1236
|
-
function writeApiKeys(list) {
|
|
1237
|
-
const p = apiKeysFilePath();
|
|
1238
|
-
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
1239
|
-
fs.writeFileSync(p, JSON.stringify(list, null, 2) + '\n', 'utf8');
|
|
1240
|
-
}
|
|
1241
|
-
function genApiKey(label) {
|
|
1242
|
-
return {
|
|
1243
|
-
id: crypto.randomBytes(8).toString('hex'),
|
|
1244
|
-
label: (String(label || '').trim() || 'Untitled').slice(0, 60),
|
|
1245
|
-
key: 'sk_' + crypto.randomBytes(24).toString('hex'),
|
|
1246
|
-
createdAt: new Date().toISOString(),
|
|
1247
|
-
};
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
1218
|
// GET/POST rate-limit config — stored in .env (read by index.js at startup).
|
|
1251
1219
|
router.get('/api/security/rate-limit', (req, res) => {
|
|
1252
1220
|
try {
|
|
@@ -1271,56 +1239,6 @@ router.post('/api/security/rate-limit', (req, res) => {
|
|
|
1271
1239
|
}
|
|
1272
1240
|
});
|
|
1273
1241
|
|
|
1274
|
-
// GET — list keys + whether the apiKey middleware is configured
|
|
1275
|
-
router.get('/api/security/api-keys', (req, res) => {
|
|
1276
|
-
try {
|
|
1277
|
-
const configured = fs.existsSync(path.join(process.cwd(), 'src', 'middleware', 'apiKey.js'));
|
|
1278
|
-
res.json({ configured, keys: readApiKeys() });
|
|
1279
|
-
} catch (err) {
|
|
1280
|
-
res.status(500).json({ error: err.message });
|
|
1281
|
-
}
|
|
1282
|
-
});
|
|
1283
|
-
|
|
1284
|
-
// POST — generate a new named key
|
|
1285
|
-
router.post('/api/security/api-keys', (req, res) => {
|
|
1286
|
-
try {
|
|
1287
|
-
const list = readApiKeys();
|
|
1288
|
-
const key = genApiKey(req.body && req.body.label);
|
|
1289
|
-
list.push(key);
|
|
1290
|
-
writeApiKeys(list);
|
|
1291
|
-
res.json({ message: 'API key generated.', key });
|
|
1292
|
-
} catch (err) {
|
|
1293
|
-
res.status(500).json({ error: err.message });
|
|
1294
|
-
}
|
|
1295
|
-
});
|
|
1296
|
-
|
|
1297
|
-
// PATCH — rename a key
|
|
1298
|
-
router.patch('/api/security/api-keys/:id', (req, res) => {
|
|
1299
|
-
try {
|
|
1300
|
-
const list = readApiKeys();
|
|
1301
|
-
const key = list.find((k) => k.id === req.params.id);
|
|
1302
|
-
if (!key) return res.status(404).json({ error: 'Key not found.' });
|
|
1303
|
-
key.label = (String((req.body && req.body.label) || '').trim() || key.label).slice(0, 60);
|
|
1304
|
-
writeApiKeys(list);
|
|
1305
|
-
res.json({ message: 'API key renamed.', key });
|
|
1306
|
-
} catch (err) {
|
|
1307
|
-
res.status(500).json({ error: err.message });
|
|
1308
|
-
}
|
|
1309
|
-
});
|
|
1310
|
-
|
|
1311
|
-
// DELETE — revoke a key
|
|
1312
|
-
router.delete('/api/security/api-keys/:id', (req, res) => {
|
|
1313
|
-
try {
|
|
1314
|
-
const before = readApiKeys();
|
|
1315
|
-
const after = before.filter((k) => k.id !== req.params.id);
|
|
1316
|
-
if (after.length === before.length) return res.status(404).json({ error: 'Key not found.' });
|
|
1317
|
-
writeApiKeys(after);
|
|
1318
|
-
res.json({ message: 'API key revoked.' });
|
|
1319
|
-
} catch (err) {
|
|
1320
|
-
res.status(500).json({ error: err.message });
|
|
1321
|
-
}
|
|
1322
|
-
});
|
|
1323
|
-
|
|
1324
1242
|
// ---------------------------------------------------------------------------
|
|
1325
1243
|
// AI Builder — describe a backend in English, generate models + CRUD routes
|
|
1326
1244
|
// ---------------------------------------------------------------------------
|
|
@@ -1564,12 +1482,164 @@ router.post('/api/ai/apply', async (req, res) => {
|
|
|
1564
1482
|
}
|
|
1565
1483
|
});
|
|
1566
1484
|
|
|
1485
|
+
// ---------------------------------------------------------------------------
|
|
1486
|
+
// API Documentation (OpenAPI spec + self-contained docs page at /docs)
|
|
1487
|
+
// ---------------------------------------------------------------------------
|
|
1488
|
+
function projectTitle() {
|
|
1489
|
+
try {
|
|
1490
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'));
|
|
1491
|
+
return pkg.name || 'API';
|
|
1492
|
+
} catch { return 'API'; }
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// All models → { name, fields[] } for the OpenAPI components/schemas.
|
|
1496
|
+
function collectModels() {
|
|
1497
|
+
const dir = path.join(process.cwd(), 'src', 'models');
|
|
1498
|
+
if (!fs.existsSync(dir)) return [];
|
|
1499
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith('.js')).map((f) => {
|
|
1500
|
+
const name = f.replace(/\.js$/, '');
|
|
1501
|
+
const detailed = getModelSchemaDetailed(name);
|
|
1502
|
+
return { name, fields: (detailed && detailed.fields) || [] };
|
|
1503
|
+
});
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
// Parse every route file into endpoint descriptors (method, path, params, query,
|
|
1507
|
+
// body fields with model types) for the OpenAPI builder.
|
|
1508
|
+
function collectEndpoints() {
|
|
1509
|
+
const routesDir = path.join(process.cwd(), 'src', 'routes');
|
|
1510
|
+
if (!fs.existsSync(routesDir)) return [];
|
|
1511
|
+
const indexPath = path.join(process.cwd(), 'index.js');
|
|
1512
|
+
const indexContent = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '';
|
|
1513
|
+
const out = [];
|
|
1514
|
+
fs.readdirSync(routesDir).filter((f) => f.endsWith('.js')).forEach((file) => {
|
|
1515
|
+
const name = file.replace(/\.js$/, '');
|
|
1516
|
+
const content = fs.readFileSync(path.join(routesDir, file), 'utf8');
|
|
1517
|
+
|
|
1518
|
+
let prefix = '/api/' + name;
|
|
1519
|
+
const reg = indexContent.match(new RegExp("app\\.use\\(['\"]([^'\"]+)['\"]\\s*,\\s*\\w+Router\\s*\\)[^\\n]*" + name, 'i'))
|
|
1520
|
+
|| indexContent.match(new RegExp("app\\.use\\(['\"]([^'\"]+)['\"]\\s*,\\s*" + toCamelCase(name) + "Router"));
|
|
1521
|
+
if (reg) prefix = reg[1];
|
|
1522
|
+
|
|
1523
|
+
const modelImportMatch = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)['"]/);
|
|
1524
|
+
const modelFields = modelImportMatch ? (getModelSchemaDetailed(modelImportMatch[2]).fields || []) : [];
|
|
1525
|
+
|
|
1526
|
+
const epRegex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]\s*,\s*([\s\S]*?)(?=\nrouter\.|\nexport |$)/gi;
|
|
1527
|
+
let m;
|
|
1528
|
+
while ((m = epRegex.exec(content)) !== null) {
|
|
1529
|
+
const method = m[1].toUpperCase();
|
|
1530
|
+
const epath = m[2];
|
|
1531
|
+
const body = m[3] || '';
|
|
1532
|
+
const bodyFields = extractRequestFields(body, 'body').map((fn) => {
|
|
1533
|
+
const meta = modelFields.find((x) => x.name === fn);
|
|
1534
|
+
return { name: fn, type: (meta && meta.type) || 'String', required: !!(meta && meta.required) };
|
|
1535
|
+
});
|
|
1536
|
+
const queryFields = extractRequestFields(body, 'query').map((fn) => ({ name: fn, type: 'String' }));
|
|
1537
|
+
const params = (epath.match(/:(\w+)/g) || []).map((p) => p.slice(1));
|
|
1538
|
+
const hasAuth = /authorization|jwt|bearer|x-api-key/i.test(body);
|
|
1539
|
+
const fullPath = (prefix + (epath === '/' ? '' : epath)).replace(/\/{2,}/g, '/');
|
|
1540
|
+
out.push({ method, path: epath, fullPath, params, queryFields, bodyFields, fileFields: [], hasAuth });
|
|
1541
|
+
}
|
|
1542
|
+
});
|
|
1543
|
+
return out;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
function generateSpecFile() {
|
|
1547
|
+
const spec = buildOpenApiSpec({ title: projectTitle(), endpoints: collectEndpoints(), models: collectModels() });
|
|
1548
|
+
fs.writeFileSync(path.join(process.cwd(), 'src', 'openapi.json'), JSON.stringify(spec, null, 2), 'utf8');
|
|
1549
|
+
return spec;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// Keep the API docs in sync automatically: whenever routes or models change, the
|
|
1553
|
+
// spec is regenerated — but only if docs have been set up. Writing openapi.json is
|
|
1554
|
+
// a .json write, so it doesn't trigger a nodemon restart.
|
|
1555
|
+
function refreshDocsSpec() {
|
|
1556
|
+
try {
|
|
1557
|
+
if (fs.existsSync(path.join(process.cwd(), 'src', 'docs.js'))) generateSpecFile();
|
|
1558
|
+
} catch {}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
const docsAuthFile = () => path.join(process.cwd(), 'src', 'docs-auth.json');
|
|
1562
|
+
|
|
1563
|
+
// GET /api/docs — setup status + endpoint count + whether docs are password-protected
|
|
1564
|
+
router.get('/api/docs', (req, res) => {
|
|
1565
|
+
try {
|
|
1566
|
+
const configured = fs.existsSync(path.join(process.cwd(), 'src', 'docs.js'));
|
|
1567
|
+
res.json({ configured, endpointCount: collectEndpoints().length, passwordProtected: fs.existsSync(docsAuthFile()) });
|
|
1568
|
+
} catch (err) {
|
|
1569
|
+
res.status(500).json({ error: err.message });
|
|
1570
|
+
}
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
// POST /api/docs/setup — create the docs route, generate the spec, wire index.js
|
|
1574
|
+
router.post('/api/docs/setup', (req, res) => {
|
|
1575
|
+
try {
|
|
1576
|
+
// Writing src/docs.js and index.js triggers a nodemon restart (it watches .js
|
|
1577
|
+
// files). Respond FIRST so the restart can't drop this request, THEN write.
|
|
1578
|
+
res.json({ message: 'API docs set up at /docs.', endpointCount: collectEndpoints().length });
|
|
1579
|
+
fs.writeFileSync(path.join(process.cwd(), 'src', 'docs.js'), buildDocsRouter(), 'utf8');
|
|
1580
|
+
generateSpecFile();
|
|
1581
|
+
addImportToIndex("import docsRouter from './src/docs.js';");
|
|
1582
|
+
const reg = "app.use('/docs', docsRouter);";
|
|
1583
|
+
if (!readIndexFile().includes(reg)) insertBeforeListen(reg);
|
|
1584
|
+
} catch (err) {
|
|
1585
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
1586
|
+
}
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
// POST /api/docs/regenerate — rebuild the spec + refresh the docs page
|
|
1590
|
+
router.post('/api/docs/regenerate', (req, res) => {
|
|
1591
|
+
try {
|
|
1592
|
+
const docsPath = path.join(process.cwd(), 'src', 'docs.js');
|
|
1593
|
+
if (!fs.existsSync(docsPath)) {
|
|
1594
|
+
return res.status(400).json({ error: 'Set up API docs first.' });
|
|
1595
|
+
}
|
|
1596
|
+
res.json({ message: 'API spec regenerated.', endpointCount: collectEndpoints().length });
|
|
1597
|
+
generateSpecFile();
|
|
1598
|
+
fs.writeFileSync(docsPath, buildDocsRouter(), 'utf8');
|
|
1599
|
+
} catch (err) {
|
|
1600
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
1601
|
+
}
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
// POST /api/docs/password — protect /docs with a password (single password, no username)
|
|
1605
|
+
router.post('/api/docs/password', (req, res) => {
|
|
1606
|
+
try {
|
|
1607
|
+
const password = String((req.body && req.body.password) || '');
|
|
1608
|
+
if (password.length < 6) {
|
|
1609
|
+
return res.status(400).json({ error: 'Password must be at least 6 characters.' });
|
|
1610
|
+
}
|
|
1611
|
+
const hash = crypto.createHash('sha256').update(password).digest('hex');
|
|
1612
|
+
fs.mkdirSync(path.join(process.cwd(), 'src'), { recursive: true });
|
|
1613
|
+
fs.writeFileSync(docsAuthFile(), JSON.stringify({ hash }, null, 2) + '\n', 'utf8');
|
|
1614
|
+
const docsPath = path.join(process.cwd(), 'src', 'docs.js');
|
|
1615
|
+
const needsUpgrade = fs.existsSync(docsPath) && !fs.readFileSync(docsPath, 'utf8').includes('docs_auth');
|
|
1616
|
+
res.json({
|
|
1617
|
+
message: needsUpgrade
|
|
1618
|
+
? 'Docs password set — updating the docs route (server will restart).'
|
|
1619
|
+
: 'Docs are now password-protected.',
|
|
1620
|
+
});
|
|
1621
|
+
if (needsUpgrade) fs.writeFileSync(docsPath, buildDocsRouter(), 'utf8');
|
|
1622
|
+
} catch (err) {
|
|
1623
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
// DELETE /api/docs/password — remove protection (docs become open again)
|
|
1628
|
+
router.delete('/api/docs/password', (req, res) => {
|
|
1629
|
+
try {
|
|
1630
|
+
if (fs.existsSync(docsAuthFile())) fs.unlinkSync(docsAuthFile());
|
|
1631
|
+
res.json({ message: 'Docs password removed.' });
|
|
1632
|
+
} catch (err) {
|
|
1633
|
+
res.status(500).json({ error: err.message });
|
|
1634
|
+
}
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1567
1637
|
// ---------------------------------------------------------------------------
|
|
1568
1638
|
// POST /api/add-api
|
|
1569
1639
|
// ---------------------------------------------------------------------------
|
|
1570
1640
|
router.post('/api/add-api', async (req, res) => {
|
|
1571
1641
|
try {
|
|
1572
|
-
const { name, pathPrefix = '/api/', routeType, methods = [], modelName, loginField, passwordField, registerFields = [], selectedFields = [], customFields = [] } = req.body;
|
|
1642
|
+
const { name, pathPrefix = '/api/', routeType, methods = [], modelName, loginField, passwordField, registerFields = [], selectedFields = [], customFields = [], authRequired = false } = req.body;
|
|
1573
1643
|
// Legacy support: single method field
|
|
1574
1644
|
const method = req.body.method;
|
|
1575
1645
|
|
|
@@ -1596,6 +1666,7 @@ router.post('/api/add-api', async (req, res) => {
|
|
|
1596
1666
|
const allFieldsCombined = [...selectedFields.map(f => typeof f === 'string' ? { name: f, type: 'String' } : f), ...customFields];
|
|
1597
1667
|
const needsMulter = allFieldsCombined.some(f => f.type === 'File');
|
|
1598
1668
|
if (needsMulter) packagesToInstall.push('multer');
|
|
1669
|
+
if (authRequired && type !== 'login' && type !== 'register') packagesToInstall.push('jsonwebtoken');
|
|
1599
1670
|
if (packagesToInstall.length > 0) {
|
|
1600
1671
|
recordDeps(packagesToInstall);
|
|
1601
1672
|
await execAsync(`npm install ${packagesToInstall.join(' ')}`, { cwd: process.cwd() });
|
|
@@ -1930,6 +2001,21 @@ export default router;
|
|
|
1930
2001
|
content = wireValidateIntoRouteContent(content, safeName);
|
|
1931
2002
|
}
|
|
1932
2003
|
|
|
2004
|
+
// Require JWT auth on this route: ensure the auth middleware + JWT_SECRET exist,
|
|
2005
|
+
// then protect every endpoint. Login/register are exempt (they issue tokens).
|
|
2006
|
+
if (authRequired && type !== 'login' && type !== 'register') {
|
|
2007
|
+
const authPath = path.join(process.cwd(), 'src', 'middleware', 'auth.js');
|
|
2008
|
+
if (!fs.existsSync(authPath)) {
|
|
2009
|
+
fs.mkdirSync(path.dirname(authPath), { recursive: true });
|
|
2010
|
+
fs.writeFileSync(authPath, buildAuthMiddleware(), 'utf8');
|
|
2011
|
+
}
|
|
2012
|
+
const envFile = path.join(process.cwd(), '.env');
|
|
2013
|
+
if (!getEnvValue(envFile, 'JWT_SECRET')) {
|
|
2014
|
+
updateEnvFile(envFile, 'JWT_SECRET', crypto.randomBytes(32).toString('hex'));
|
|
2015
|
+
}
|
|
2016
|
+
content = wireAuthIntoRouteContent(content);
|
|
2017
|
+
}
|
|
2018
|
+
|
|
1933
2019
|
fs.writeFileSync(routeFilePath, content, 'utf8');
|
|
1934
2020
|
|
|
1935
2021
|
const label = type === 'methods' ? methodList.map(m => m.toUpperCase()).join(', ') : type === 'crud' ? 'CRUD (' + (methodList.length > 0 ? methodList.map(m => m.toUpperCase()).join(', ') : 'ALL') + ')' : type === 'plain' ? 'PLAIN' : type.toUpperCase();
|
package/skeleton/_gitignore
CHANGED
package/skeleton/index.js
CHANGED
|
@@ -8,12 +8,15 @@ import helmet from "helmet";
|
|
|
8
8
|
import rateLimit from "express-rate-limit";
|
|
9
9
|
import { createInterface } from "readline";
|
|
10
10
|
import { notFound, errorHandler } from "./src/middleware/errorHandler.js";
|
|
11
|
-
import apiKey from "./src/middleware/apiKey.js";
|
|
12
11
|
|
|
13
12
|
// ── Environment ─────────────────────────────────────
|
|
14
13
|
dotenv.config();
|
|
15
14
|
|
|
16
15
|
const app = express();
|
|
16
|
+
// Behind reverse proxies (e.g. nginx → load balancer). Lets Express read the real
|
|
17
|
+
// client IP from X-Forwarded-For so rate limiting and req.ip are correct. The number
|
|
18
|
+
// is how many proxy hops to trust — adjust to your infrastructure.
|
|
19
|
+
app.set("trust proxy", 2);
|
|
17
20
|
const port = process.env.PORT || 3000;
|
|
18
21
|
const __dirname = path.resolve();
|
|
19
22
|
|
|
@@ -54,13 +57,6 @@ const apiLimiter = rateLimit({
|
|
|
54
57
|
});
|
|
55
58
|
app.use(apiLimiter);
|
|
56
59
|
|
|
57
|
-
// ── API key gate ─────────────────────────────────────
|
|
58
|
-
// Every /api/* request must present a valid key in the `x-api-key` header.
|
|
59
|
-
// Manage keys in the dashboard (API Keys). Set API_KEY_REQUIRED=off to disable.
|
|
60
|
-
if (process.env.API_KEY_REQUIRED !== "off") {
|
|
61
|
-
app.use("/api", apiKey);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
60
|
// ── Static Files ────────────────────────────────────
|
|
65
61
|
app.use(express.static(path.join(__dirname, "public")));
|
|
66
62
|
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect } from "react";
|
|
2
|
-
import { LuKeyRound, LuPlus, LuTrash2, LuCopy, LuEye, LuEyeOff, LuPencil, LuCheck, LuX } from "react-icons/lu";
|
|
3
|
-
import { html, api, copyToClipboard } from "../lib.js";
|
|
4
|
-
import { useApp } from "../context.js";
|
|
5
|
-
|
|
6
|
-
export function ApiKeysPage() {
|
|
7
|
-
const { toast, showConfirm } = useApp();
|
|
8
|
-
const [keys, setKeys] = useState(null);
|
|
9
|
-
const [label, setLabel] = useState("");
|
|
10
|
-
const [busy, setBusy] = useState(false);
|
|
11
|
-
const [revealed, setRevealed] = useState({});
|
|
12
|
-
const [editId, setEditId] = useState(null);
|
|
13
|
-
const [editLabel, setEditLabel] = useState("");
|
|
14
|
-
|
|
15
|
-
async function load() {
|
|
16
|
-
try {
|
|
17
|
-
const r = await api("/security/api-keys");
|
|
18
|
-
setKeys(r.keys || []);
|
|
19
|
-
} catch (err) {
|
|
20
|
-
toast(err.message, "error");
|
|
21
|
-
setKeys([]);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
useEffect(() => { load(); }, []);
|
|
25
|
-
|
|
26
|
-
async function generate() {
|
|
27
|
-
setBusy(true);
|
|
28
|
-
try {
|
|
29
|
-
const r = await api("/security/api-keys", { method: "POST", body: { label: label.trim() } });
|
|
30
|
-
setLabel("");
|
|
31
|
-
setRevealed((s) => ({ ...s, [r.key.id]: true }));
|
|
32
|
-
toast("API key generated", "success");
|
|
33
|
-
await load();
|
|
34
|
-
} catch (err) {
|
|
35
|
-
toast(err.message, "error");
|
|
36
|
-
}
|
|
37
|
-
setBusy(false);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
async function revoke(k) {
|
|
41
|
-
const ok = await showConfirm(`Revoke API key "${k.label}"? Any client using it will stop working immediately.`, { danger: true, confirmLabel: "Revoke" });
|
|
42
|
-
if (!ok) return;
|
|
43
|
-
try {
|
|
44
|
-
await api("/security/api-keys/" + k.id, { method: "DELETE" });
|
|
45
|
-
toast("API key revoked", "success");
|
|
46
|
-
await load();
|
|
47
|
-
} catch (err) {
|
|
48
|
-
toast(err.message, "error");
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function startRename(k) {
|
|
53
|
-
setEditId(k.id);
|
|
54
|
-
setEditLabel(k.label);
|
|
55
|
-
}
|
|
56
|
-
function cancelRename() {
|
|
57
|
-
setEditId(null);
|
|
58
|
-
setEditLabel("");
|
|
59
|
-
}
|
|
60
|
-
async function saveRename(k) {
|
|
61
|
-
const next = editLabel.trim();
|
|
62
|
-
if (!next || next === k.label) { cancelRename(); return; }
|
|
63
|
-
try {
|
|
64
|
-
await api("/security/api-keys/" + k.id, { method: "PATCH", body: { label: next } });
|
|
65
|
-
toast("API key renamed", "success");
|
|
66
|
-
cancelRename();
|
|
67
|
-
await load();
|
|
68
|
-
} catch (err) {
|
|
69
|
-
toast(err.message, "error");
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function copy(k) {
|
|
74
|
-
copyToClipboard(k.key);
|
|
75
|
-
toast("Key copied to clipboard", "success");
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function mask(key) {
|
|
79
|
-
if (key.length <= 12) return key;
|
|
80
|
-
return key.slice(0, 7) + "…" + key.slice(-4);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function fmtDate(iso) {
|
|
84
|
-
if (!iso) return "";
|
|
85
|
-
try { return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }
|
|
86
|
-
catch { return ""; }
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return html`<div>
|
|
90
|
-
<div className="flex items-center gap-2 mb-1">
|
|
91
|
-
<${LuKeyRound} size=${20} />
|
|
92
|
-
<div className="card-title" style=${{ margin: 0 }}>API Keys</div>
|
|
93
|
-
</div>
|
|
94
|
-
<div className="card-subtitle mb-4">
|
|
95
|
-
Named keys for machine/service access. A caller sends one in the
|
|
96
|
-
<span style=${{ fontFamily: "monospace" }}> x-api-key</span> header.
|
|
97
|
-
Protect a route with <span style=${{ fontFamily: "monospace" }}>apiKey</span> middleware:
|
|
98
|
-
<span style=${{ fontFamily: "monospace" }}>app.use('/api/private', apiKey)</span>.
|
|
99
|
-
Keys live in <span style=${{ fontFamily: "monospace" }}>src/api-keys.json</span> and take effect immediately.
|
|
100
|
-
</div>
|
|
101
|
-
|
|
102
|
-
<div className="card">
|
|
103
|
-
<div style=${{ display: "flex", gap: "8px", marginBottom: "16px", flexWrap: "wrap" }}>
|
|
104
|
-
<input className="form-input" placeholder="Key name (e.g. Mobile app, Partner X)"
|
|
105
|
-
value=${label} maxLength=${60}
|
|
106
|
-
onInput=${(e) => setLabel(e.target.value)}
|
|
107
|
-
onKeyDown=${(e) => { if (e.key === "Enter" && !busy) generate(); }}
|
|
108
|
-
style=${{ flex: 1, minWidth: "220px" }} />
|
|
109
|
-
<button className="btn btn-primary" onClick=${generate} disabled=${busy}>
|
|
110
|
-
${busy ? html`<span className="spinner" />` : html`<${LuPlus} size=${15} />`} Generate key
|
|
111
|
-
</button>
|
|
112
|
-
</div>
|
|
113
|
-
|
|
114
|
-
${keys === null
|
|
115
|
-
? html`<div className="text-muted text-sm">Loading…</div>`
|
|
116
|
-
: keys.length === 0
|
|
117
|
-
? html`<div className="text-muted text-sm">No keys yet — generate one above.</div>`
|
|
118
|
-
: html`<div style=${{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
|
119
|
-
${keys.map((k) => html`<div key=${k.id}
|
|
120
|
-
style=${{ display: "flex", alignItems: "center", gap: "10px", padding: "10px 12px",
|
|
121
|
-
border: "1px solid var(--border)", borderRadius: "10px", background: "var(--bg-surface)" }}>
|
|
122
|
-
<div style=${{ flex: 1, minWidth: 0 }}>
|
|
123
|
-
${editId === k.id
|
|
124
|
-
? html`<input className="form-input" autoFocus value=${editLabel} maxLength=${60}
|
|
125
|
-
onInput=${(e) => setEditLabel(e.target.value)}
|
|
126
|
-
onKeyDown=${(e) => { if (e.key === "Enter") saveRename(k); if (e.key === "Escape") cancelRename(); }}
|
|
127
|
-
style=${{ fontSize: "13px", padding: "4px 8px", marginBottom: "2px", maxWidth: "260px" }} />`
|
|
128
|
-
: html`<div style=${{ fontWeight: 600, fontSize: "13px" }}>${k.label}</div>`}
|
|
129
|
-
<div style=${{ fontFamily: "monospace", fontSize: "12px", color: "var(--text-muted)" }}>
|
|
130
|
-
${revealed[k.id] ? k.key : mask(k.key)}
|
|
131
|
-
${k.createdAt && html`<span style=${{ marginLeft: "8px" }}>· ${fmtDate(k.createdAt)}</span>`}
|
|
132
|
-
</div>
|
|
133
|
-
</div>
|
|
134
|
-
${editId === k.id
|
|
135
|
-
? html`<${React.Fragment}>
|
|
136
|
-
<button className="btn btn-sm btn-ghost" title="Save" onClick=${() => saveRename(k)}
|
|
137
|
-
style=${{ color: "var(--success, #22c55e)" }}>
|
|
138
|
-
<${LuCheck} size=${15} />
|
|
139
|
-
</button>
|
|
140
|
-
<button className="btn btn-sm btn-ghost" title="Cancel" onClick=${cancelRename}>
|
|
141
|
-
<${LuX} size=${15} />
|
|
142
|
-
</button>
|
|
143
|
-
<//>`
|
|
144
|
-
: html`<${React.Fragment}>
|
|
145
|
-
<button className="btn btn-sm btn-ghost" title="Rename" onClick=${() => startRename(k)}>
|
|
146
|
-
<${LuPencil} size=${15} />
|
|
147
|
-
</button>
|
|
148
|
-
<button className="btn btn-sm btn-ghost" title=${revealed[k.id] ? "Hide" : "Reveal"}
|
|
149
|
-
onClick=${() => setRevealed((s) => ({ ...s, [k.id]: !s[k.id] }))}>
|
|
150
|
-
${revealed[k.id] ? html`<${LuEyeOff} size=${15} />` : html`<${LuEye} size=${15} />`}
|
|
151
|
-
</button>
|
|
152
|
-
<button className="btn btn-sm btn-ghost" title="Copy" onClick=${() => copy(k)}>
|
|
153
|
-
<${LuCopy} size=${15} />
|
|
154
|
-
</button>
|
|
155
|
-
<button className="btn btn-sm btn-ghost" title="Revoke" onClick=${() => revoke(k)}
|
|
156
|
-
style=${{ color: "var(--danger, #ef4444)" }}>
|
|
157
|
-
<${LuTrash2} size=${15} />
|
|
158
|
-
</button>
|
|
159
|
-
<//>`}
|
|
160
|
-
</div>`)}
|
|
161
|
-
</div>`}
|
|
162
|
-
</div>
|
|
163
|
-
</div>`;
|
|
164
|
-
}
|