webhanger 1.0.1 → 1.0.5
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/README.md +472 -82
- package/bin/cli.js +459 -4
- package/core/builder.js +133 -0
- package/core/registry.js +15 -6
- package/core/resolver.js +63 -0
- package/helper/accessControl.js +112 -0
- package/helper/breakdown.js +63 -0
- package/helper/bundler.js +17 -25
- package/helper/crypto.js +39 -0
- package/index.js +3 -0
- package/package.json +6 -2
package/core/resolver.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { getComponent } from "../helper/dbHandler.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves a full dependency graph for a component.
|
|
5
|
+
* Returns a flat ordered array of components to load — deps first, then the component.
|
|
6
|
+
* Detects circular dependencies and throws.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} dbConfig
|
|
9
|
+
* @param {string} projectId
|
|
10
|
+
* @param {string} name
|
|
11
|
+
* @param {string} version
|
|
12
|
+
* @returns {Array<{name, version, cdnUrl, token, expires}>}
|
|
13
|
+
*/
|
|
14
|
+
export async function resolve(dbConfig, projectId, name, version) {
|
|
15
|
+
const visited = new Set();
|
|
16
|
+
const order = [];
|
|
17
|
+
|
|
18
|
+
async function walk(n, v, chain = []) {
|
|
19
|
+
const key = `${n}@${v}`;
|
|
20
|
+
|
|
21
|
+
// Circular dependency check
|
|
22
|
+
if (chain.includes(key)) {
|
|
23
|
+
throw new Error(`Circular dependency detected: ${[...chain, key].join(" → ")}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Already resolved
|
|
27
|
+
if (visited.has(key)) return;
|
|
28
|
+
visited.add(key);
|
|
29
|
+
|
|
30
|
+
const meta = await getComponent(dbConfig, projectId, n, v);
|
|
31
|
+
if (!meta) throw new Error(`Component ${key} not found in registry.`);
|
|
32
|
+
|
|
33
|
+
// Walk dependencies first (depth-first)
|
|
34
|
+
if (meta.dependencies && meta.dependencies.length) {
|
|
35
|
+
for (const dep of meta.dependencies) {
|
|
36
|
+
const [depName, depVersion] = parseDep(dep);
|
|
37
|
+
await walk(depName, depVersion, [...chain, key]);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
order.push({
|
|
42
|
+
name: n,
|
|
43
|
+
version: v,
|
|
44
|
+
cdnUrl: meta.cdnUrl,
|
|
45
|
+
token: meta.token,
|
|
46
|
+
expires: meta.expires,
|
|
47
|
+
assets: meta.assets || []
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
await walk(name, version);
|
|
52
|
+
return order;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parses "navbar@1.2.0" → ["navbar", "1.2.0"]
|
|
57
|
+
* Parses "navbar" → ["navbar", "latest"]
|
|
58
|
+
*/
|
|
59
|
+
export function parseDep(dep) {
|
|
60
|
+
const atIndex = dep.lastIndexOf("@");
|
|
61
|
+
if (atIndex <= 0) return [dep, "latest"];
|
|
62
|
+
return [dep.slice(0, atIndex), dep.slice(atIndex + 1)];
|
|
63
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebHanger Access Control
|
|
3
|
+
* Role-based permissions stored in DB per project.
|
|
4
|
+
*
|
|
5
|
+
* Roles: owner | admin | deployer | viewer
|
|
6
|
+
* Actions: deploy | read | delete | manage_access
|
|
7
|
+
*
|
|
8
|
+
* Storage: projects/{projectId}/access/{apiKey}
|
|
9
|
+
* {
|
|
10
|
+
* role: "deployer",
|
|
11
|
+
* permissions: ["deploy", "read"],
|
|
12
|
+
* createdAt: ...,
|
|
13
|
+
* label: "CI/CD key"
|
|
14
|
+
* }
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import crypto from "crypto";
|
|
18
|
+
|
|
19
|
+
// ─── Permission matrix ────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const ROLE_PERMISSIONS = {
|
|
22
|
+
owner: ["deploy", "read", "delete", "manage_access"],
|
|
23
|
+
admin: ["deploy", "read", "delete", "manage_access"],
|
|
24
|
+
deployer: ["deploy", "read"],
|
|
25
|
+
viewer: ["read"]
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ─── Key generation ───────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
export function generateApiKey() {
|
|
31
|
+
return `wh_key_${crypto.randomBytes(24).toString("hex")}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ─── Firebase RBAC ───────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
async function getFirestore(serviceAccountPath) {
|
|
37
|
+
const admin = (await import("firebase-admin")).default;
|
|
38
|
+
const fs = (await import("fs-extra")).default;
|
|
39
|
+
const serviceAccount = fs.readJsonSync(serviceAccountPath);
|
|
40
|
+
if (!admin.apps.length) {
|
|
41
|
+
admin.initializeApp({ credential: admin.credential.cert(serviceAccount) });
|
|
42
|
+
}
|
|
43
|
+
return admin.firestore();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Grant a role to an API key for a project.
|
|
48
|
+
*/
|
|
49
|
+
export async function grantAccess(dbConfig, projectId, apiKey, role, label = "") {
|
|
50
|
+
if (!ROLE_PERMISSIONS[role]) throw new Error(`Unknown role: ${role}. Valid: ${Object.keys(ROLE_PERMISSIONS).join(", ")}`);
|
|
51
|
+
|
|
52
|
+
if (dbConfig.provider === "firebase") {
|
|
53
|
+
const db = await getFirestore(dbConfig.serviceAccountPath);
|
|
54
|
+
await db.collection("projects").doc(projectId)
|
|
55
|
+
.collection("access").doc(apiKey)
|
|
56
|
+
.set({
|
|
57
|
+
role,
|
|
58
|
+
permissions: ROLE_PERMISSIONS[role],
|
|
59
|
+
label,
|
|
60
|
+
createdAt: new Date().toISOString()
|
|
61
|
+
});
|
|
62
|
+
return { apiKey, role, permissions: ROLE_PERMISSIONS[role] };
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`Access control not yet supported for provider: ${dbConfig.provider}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Revoke access for an API key.
|
|
69
|
+
*/
|
|
70
|
+
export async function revokeAccess(dbConfig, projectId, apiKey) {
|
|
71
|
+
if (dbConfig.provider === "firebase") {
|
|
72
|
+
const db = await getFirestore(dbConfig.serviceAccountPath);
|
|
73
|
+
await db.collection("projects").doc(projectId)
|
|
74
|
+
.collection("access").doc(apiKey).delete();
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`Access control not yet supported for provider: ${dbConfig.provider}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if an API key has permission to perform an action.
|
|
82
|
+
* Returns the access record or throws if unauthorized.
|
|
83
|
+
*/
|
|
84
|
+
export async function checkPermission(dbConfig, projectId, apiKey, action) {
|
|
85
|
+
if (dbConfig.provider === "firebase") {
|
|
86
|
+
const db = await getFirestore(dbConfig.serviceAccountPath);
|
|
87
|
+
const snap = await db.collection("projects").doc(projectId)
|
|
88
|
+
.collection("access").doc(apiKey).get();
|
|
89
|
+
|
|
90
|
+
if (!snap.exists) throw new Error("Unauthorized: API key not found.");
|
|
91
|
+
|
|
92
|
+
const access = snap.data();
|
|
93
|
+
if (!access.permissions.includes(action)) {
|
|
94
|
+
throw new Error(`Forbidden: role "${access.role}" cannot perform "${action}".`);
|
|
95
|
+
}
|
|
96
|
+
return access;
|
|
97
|
+
}
|
|
98
|
+
throw new Error(`Access control not yet supported for provider: ${dbConfig.provider}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* List all access entries for a project.
|
|
103
|
+
*/
|
|
104
|
+
export async function listAccess(dbConfig, projectId) {
|
|
105
|
+
if (dbConfig.provider === "firebase") {
|
|
106
|
+
const db = await getFirestore(dbConfig.serviceAccountPath);
|
|
107
|
+
const snap = await db.collection("projects").doc(projectId)
|
|
108
|
+
.collection("access").get();
|
|
109
|
+
return snap.docs.map(d => ({ apiKey: d.id, ...d.data() }));
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`Access control not yet supported for provider: ${dbConfig.provider}`);
|
|
112
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Breakdown Engine
|
|
6
|
+
* Detects a single HTML file with embedded <style> and <script> tags.
|
|
7
|
+
* Extracts them into separate index.html, style.css, script.js files.
|
|
8
|
+
* Works on component folders OR standalone HTML files.
|
|
9
|
+
*/
|
|
10
|
+
export async function breakdown(componentDir) {
|
|
11
|
+
const files = await fs.readdir(componentDir);
|
|
12
|
+
const htmlFiles = files.filter(f => f.endsWith(".html"));
|
|
13
|
+
const hasCSS = files.some(f => f.endsWith(".css"));
|
|
14
|
+
const hasJS = files.some(f => f.endsWith(".js") && f !== "webhanger.component.json");
|
|
15
|
+
|
|
16
|
+
// Only run if there's exactly one HTML file and no separate css/js yet
|
|
17
|
+
if (htmlFiles.length !== 1 || (hasCSS && hasJS)) return false;
|
|
18
|
+
|
|
19
|
+
const htmlPath = path.join(componentDir, htmlFiles[0]);
|
|
20
|
+
const raw = await fs.readFile(htmlPath, "utf-8");
|
|
21
|
+
|
|
22
|
+
// Extract all <style> blocks
|
|
23
|
+
const styleMatches = [...raw.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)];
|
|
24
|
+
const css = styleMatches.map(m => m[1].trim()).join("\n\n");
|
|
25
|
+
|
|
26
|
+
// Extract all <script> blocks (non-src)
|
|
27
|
+
const scriptMatches = [...raw.matchAll(/<script(?![^>]*src)[^>]*>([\s\S]*?)<\/script>/gi)];
|
|
28
|
+
const js = scriptMatches.map(m => m[1].trim()).join("\n\n");
|
|
29
|
+
|
|
30
|
+
// Strip <style> and <script> from HTML, clean up blank lines
|
|
31
|
+
let html = raw
|
|
32
|
+
.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
|
|
33
|
+
.replace(/<script(?![^>]*src)[^>]*>[\s\S]*?<\/script>/gi, "")
|
|
34
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
35
|
+
.trim();
|
|
36
|
+
|
|
37
|
+
// Strip outer <html><head><body> wrapper if present — keep inner content only
|
|
38
|
+
const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
|
|
39
|
+
if (bodyMatch) html = bodyMatch[1].trim();
|
|
40
|
+
|
|
41
|
+
const changed = css || js;
|
|
42
|
+
if (!changed) return false;
|
|
43
|
+
|
|
44
|
+
// Write extracted files
|
|
45
|
+
await fs.writeFile(path.join(componentDir, "index.html"), html, "utf-8");
|
|
46
|
+
if (css) await fs.writeFile(path.join(componentDir, "style.css"), css, "utf-8");
|
|
47
|
+
if (js) await fs.writeFile(path.join(componentDir, "script.js"), js, "utf-8");
|
|
48
|
+
|
|
49
|
+
// Rename original if it wasn't index.html
|
|
50
|
+
if (htmlFiles[0] !== "index.html") {
|
|
51
|
+
await fs.remove(htmlPath);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
extracted: {
|
|
56
|
+
html: !!html,
|
|
57
|
+
css: !!css,
|
|
58
|
+
js: !!js
|
|
59
|
+
},
|
|
60
|
+
cssLines: css.split("\n").length,
|
|
61
|
+
jsLines: js.split("\n").length
|
|
62
|
+
};
|
|
63
|
+
}
|
package/helper/bundler.js
CHANGED
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
import fs from "fs-extra";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { autoGenerateComponentMeta } from "./analyzer.js";
|
|
4
|
+
import { encrypt, integrityHash } from "./crypto.js";
|
|
5
|
+
import { breakdown } from "./breakdown.js";
|
|
4
6
|
|
|
5
|
-
function xorEncode(str, key) {
|
|
6
|
-
let out = "";
|
|
7
|
-
for (let i = 0; i < str.length; i++) {
|
|
8
|
-
out += String.fromCharCode(str.charCodeAt(i) ^ key.charCodeAt(i % key.length));
|
|
9
|
-
}
|
|
10
|
-
return out;
|
|
11
|
-
}
|
|
12
7
|
|
|
13
8
|
/**
|
|
14
9
|
* Bundles html+css+js into an encrypted JSON payload.
|
|
@@ -16,6 +11,12 @@ function xorEncode(str, key) {
|
|
|
16
11
|
* Each chunk is XOR-encoded with projectId + chunk-type salt, then base64.
|
|
17
12
|
*/
|
|
18
13
|
export async function bundle(componentDir, projectId) {
|
|
14
|
+
// Auto-breakdown: extract <style>/<script> from single HTML file if needed
|
|
15
|
+
const broke = await breakdown(componentDir);
|
|
16
|
+
if (broke) {
|
|
17
|
+
console.log(` ✓ Breakdown: extracted CSS (${broke.cssLines} lines) + JS (${broke.jsLines} lines) from single file`);
|
|
18
|
+
}
|
|
19
|
+
|
|
19
20
|
// Auto-detect + generate webhanger.component.json before bundling
|
|
20
21
|
const { analysis } = await autoGenerateComponentMeta(componentDir);
|
|
21
22
|
console.log(` ✓ Framework detected: ${analysis.framework}`);
|
|
@@ -44,26 +45,17 @@ export async function bundle(componentDir, projectId) {
|
|
|
44
45
|
throw new Error("Component must have at least an .html or .js file.");
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
const encrypt = (content, salt) => {
|
|
50
|
-
if (!content) return "";
|
|
51
|
-
const key = projectId + salt;
|
|
52
|
-
const bytes = Buffer.from(content, "utf-8");
|
|
53
|
-
const keyBytes = Buffer.from(key, "utf-8");
|
|
54
|
-
const out = Buffer.alloc(bytes.length);
|
|
55
|
-
for (let i = 0; i < bytes.length; i++) {
|
|
56
|
-
out[i] = bytes[i] ^ keyBytes[i % keyBytes.length];
|
|
57
|
-
}
|
|
58
|
-
return out.toString("base64");
|
|
59
|
-
};
|
|
48
|
+
// AES-256-GCM encrypt each chunk with per-chunk salt
|
|
49
|
+
const encryptChunk = (content, salt) => encrypt(content, projectId, salt);
|
|
60
50
|
|
|
61
51
|
const payload = {
|
|
62
|
-
v:
|
|
63
|
-
h:
|
|
64
|
-
c:
|
|
65
|
-
j:
|
|
66
|
-
assets: meta.assets || []
|
|
52
|
+
v: 2, // version 2 = AES encrypted
|
|
53
|
+
h: encryptChunk(html, "::html"),
|
|
54
|
+
c: encryptChunk(css, "::css"),
|
|
55
|
+
j: encryptChunk(js, "::js"),
|
|
56
|
+
assets: meta.assets || [],
|
|
57
|
+
dependencies: meta.dependencies || [],
|
|
58
|
+
integrity: integrityHash(html + css + js) // tamper detection
|
|
67
59
|
};
|
|
68
60
|
|
|
69
61
|
return JSON.stringify(payload);
|
package/helper/crypto.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
|
|
3
|
+
const ALGO = "aes-256-gcm";
|
|
4
|
+
const KEY_LEN = 32;
|
|
5
|
+
const IV_LEN = 12;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Derives a 256-bit AES key from SHA-256(projectId + salt)
|
|
9
|
+
* Simple, fast, consistent between Node and browser.
|
|
10
|
+
*/
|
|
11
|
+
function deriveKey(projectId, salt) {
|
|
12
|
+
return crypto.createHash("sha256").update(projectId + salt).digest();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function encrypt(plaintext, projectId, salt = "::wh") {
|
|
16
|
+
if (!plaintext) return "";
|
|
17
|
+
const key = deriveKey(projectId, salt);
|
|
18
|
+
const iv = crypto.randomBytes(IV_LEN);
|
|
19
|
+
const cipher = crypto.createCipheriv(ALGO, key, iv);
|
|
20
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf-8"), cipher.final()]);
|
|
21
|
+
const tag = cipher.getAuthTag();
|
|
22
|
+
return `${iv.toString("base64")}:${tag.toString("base64")}:${encrypted.toString("base64")}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function decrypt(encoded, projectId, salt = "::wh") {
|
|
26
|
+
if (!encoded) return "";
|
|
27
|
+
const [ivB64, tagB64, dataB64] = encoded.split(":");
|
|
28
|
+
const key = deriveKey(projectId, salt);
|
|
29
|
+
const iv = Buffer.from(ivB64, "base64");
|
|
30
|
+
const tag = Buffer.from(tagB64, "base64");
|
|
31
|
+
const data = Buffer.from(dataB64, "base64");
|
|
32
|
+
const decipher = crypto.createDecipheriv(ALGO, key, iv);
|
|
33
|
+
decipher.setAuthTag(tag);
|
|
34
|
+
return Buffer.concat([decipher.update(data), decipher.final()]).toString("utf-8");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function integrityHash(content) {
|
|
38
|
+
return crypto.createHash("sha256").update(content).digest("hex");
|
|
39
|
+
}
|
package/index.js
CHANGED
|
@@ -149,6 +149,9 @@ export { upload, remove } from "./helper/bucketHandler.js";
|
|
|
149
149
|
export { registerComponent, getComponent } from "./helper/dbHandler.js";
|
|
150
150
|
export { provisionBucket, provisionCloudFront } from "./helper/awsProvisioner.js";
|
|
151
151
|
export { deploy } from "./core/registry.js";
|
|
152
|
+
export { resolve as resolveGraph } from "./core/resolver.js";
|
|
152
153
|
export { default as loadConfig } from "./helper/loadConfig.js";
|
|
153
154
|
export { analyzeComponent, autoGenerateComponentMeta } from "./helper/analyzer.js";
|
|
154
155
|
export { convert } from "./helper/converter.js";
|
|
156
|
+
export { build } from "./core/builder.js";
|
|
157
|
+
export { grantAccess, revokeAccess, checkPermission, listAccess, generateApiKey } from "./helper/accessControl.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webhanger",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "Component-as-a-Service platform — bundle, sign, and deliver UI components via edge CDN",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -28,12 +28,16 @@
|
|
|
28
28
|
"author": "",
|
|
29
29
|
"license": "ISC",
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@aws-sdk/client-s3": "^3.0.0",
|
|
32
31
|
"@aws-sdk/client-cloudfront": "^3.0.0",
|
|
32
|
+
"@aws-sdk/client-s3": "^3.0.0",
|
|
33
33
|
"@aws-sdk/s3-request-presigner": "^3.0.0",
|
|
34
|
+
"archiver": "^7.0.1",
|
|
34
35
|
"chalk": "^5.6.2",
|
|
35
36
|
"firebase-admin": "^12.0.0",
|
|
36
37
|
"fs-extra": "^11.3.4",
|
|
37
38
|
"inquirer": "^13.4.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"terser": "^5.46.1"
|
|
38
42
|
}
|
|
39
43
|
}
|