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.
@@ -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
- // UTF-8 safe XOR operate on bytes not char codes
48
- // Fixes special chars (©, accents, emoji) breaking atob in browser
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: 1,
63
- h: encrypt(html, "::html"),
64
- c: encrypt(css, "::css"),
65
- j: encrypt(js, "::js"),
66
- assets: meta.assets || [] // CDN assets passed in plaintext — they're public URLs
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);
@@ -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.1",
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
  }