webhanger 1.0.8 → 1.1.0
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 +599 -313
- package/bin/cli.js +304 -7
- package/core/devServer.js +337 -0
- package/helper/analyzer.js +32 -5
- package/helper/authConfig.js +68 -0
- package/helper/bundler.js +3 -2
- package/helper/dbHandler.js +11 -4
- package/helper/oauthHandler.js +149 -0
- package/helper/personalization.js +138 -0
- package/package.json +4 -2
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebHanger Dev Server
|
|
3
|
+
* - Watches component files for changes
|
|
4
|
+
* - Auto-deploys on save (bundle → encrypt → upload → register)
|
|
5
|
+
* - Serves a live preview with hot reload
|
|
6
|
+
* - Auto-refreshes wh-manifest.json
|
|
7
|
+
* - Tests dependency graph on every change
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import http from "http";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import fs from "fs-extra";
|
|
13
|
+
import { fileURLToPath } from "url";
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
// ─── File watcher (no deps — uses fs.watch) ───────────────────────────────────
|
|
18
|
+
|
|
19
|
+
function watchDir(dir, onChange) {
|
|
20
|
+
const watchers = new Map();
|
|
21
|
+
|
|
22
|
+
function watchRecursive(d) {
|
|
23
|
+
if (!fs.existsSync(d)) return;
|
|
24
|
+
const watcher = fs.watch(d, { recursive: false }, (event, filename) => {
|
|
25
|
+
if (filename) onChange(path.join(d, filename), event);
|
|
26
|
+
});
|
|
27
|
+
watchers.set(d, watcher);
|
|
28
|
+
|
|
29
|
+
for (const entry of fs.readdirSync(d)) {
|
|
30
|
+
const full = path.join(d, entry);
|
|
31
|
+
if (fs.statSync(full).isDirectory()) watchRecursive(full);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
watchRecursive(dir);
|
|
36
|
+
return () => watchers.forEach(w => w.close());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── SSE broadcaster (for hot reload) ────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function createSSE() {
|
|
42
|
+
const clients = new Set();
|
|
43
|
+
|
|
44
|
+
function addClient(res) {
|
|
45
|
+
res.writeHead(200, {
|
|
46
|
+
"Content-Type": "text/event-stream",
|
|
47
|
+
"Cache-Control": "no-cache",
|
|
48
|
+
"Connection": "keep-alive",
|
|
49
|
+
"Access-Control-Allow-Origin": "*"
|
|
50
|
+
});
|
|
51
|
+
res.write("data: connected\n\n");
|
|
52
|
+
clients.add(res);
|
|
53
|
+
res.on("close", () => clients.delete(res));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function broadcast(event, data) {
|
|
57
|
+
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
58
|
+
clients.forEach(res => { try { res.write(msg); } catch (_) {} });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { addClient, broadcast, clientCount: () => clients.size };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─── Dev server ───────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export async function startDevServer(config, componentsDir, options = {}) {
|
|
67
|
+
const PORT = options.port || 4242;
|
|
68
|
+
const manifestOut = options.manifestOut || path.join(process.cwd(), "wh-manifest.json");
|
|
69
|
+
|
|
70
|
+
const { deploy } = await import("./registry.js");
|
|
71
|
+
const { resolveGraph } = await import("./resolver.js");
|
|
72
|
+
|
|
73
|
+
const sse = createSSE();
|
|
74
|
+
const deployQueue = new Map(); // debounce per component
|
|
75
|
+
const deployedComponents = new Map();
|
|
76
|
+
|
|
77
|
+
// ── Scan component folders ────────────────────────────────────────────────
|
|
78
|
+
async function scanComponents() {
|
|
79
|
+
const entries = await fs.readdir(componentsDir);
|
|
80
|
+
const comps = [];
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
const full = path.join(componentsDir, entry);
|
|
83
|
+
const stat = await fs.stat(full);
|
|
84
|
+
if (stat.isDirectory()) comps.push({ name: entry, dir: full });
|
|
85
|
+
}
|
|
86
|
+
return comps;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Deploy a single component ─────────────────────────────────────────────
|
|
90
|
+
async function deployComponent(name, dir, version = "dev") {
|
|
91
|
+
const start = Date.now();
|
|
92
|
+
try {
|
|
93
|
+
const result = await deploy(config, dir, name, version);
|
|
94
|
+
deployedComponents.set(name, result);
|
|
95
|
+
await refreshManifest();
|
|
96
|
+
|
|
97
|
+
const elapsed = Date.now() - start;
|
|
98
|
+
sse.broadcast("component-updated", {
|
|
99
|
+
name, version, cdnUrl: result.cdnUrl,
|
|
100
|
+
time: elapsed, ts: Date.now()
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return { success: true, name, elapsed };
|
|
104
|
+
} catch (err) {
|
|
105
|
+
sse.broadcast("deploy-error", { name, message: err.message });
|
|
106
|
+
return { success: false, name, error: err.message };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Refresh manifest ──────────────────────────────────────────────────────
|
|
111
|
+
async function refreshManifest() {
|
|
112
|
+
const manifest = {
|
|
113
|
+
pid: config.projectId,
|
|
114
|
+
components: {}
|
|
115
|
+
};
|
|
116
|
+
for (const [name, d] of deployedComponents) {
|
|
117
|
+
manifest.components[name] = {
|
|
118
|
+
url: d.cdnUrl,
|
|
119
|
+
urls: d.cdnUrls || [d.cdnUrl],
|
|
120
|
+
token: d.token,
|
|
121
|
+
expires: d.expires
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
await fs.writeJson(manifestOut, manifest, { spaces: 2 });
|
|
125
|
+
sse.broadcast("manifest-updated", { componentCount: deployedComponents.size, ts: Date.now() });
|
|
126
|
+
return manifest;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Test dependency graph ─────────────────────────────────────────────────
|
|
130
|
+
async function testDependencyGraph(rootName) {
|
|
131
|
+
try {
|
|
132
|
+
const graph = await resolveGraph(config.db, config.projectId, rootName, "dev");
|
|
133
|
+
sse.broadcast("dep-graph", { root: rootName, graph: graph.map(c => `${c.name}@${c.version}`) });
|
|
134
|
+
return graph;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
sse.broadcast("dep-error", { root: rootName, message: err.message });
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Watch for file changes ────────────────────────────────────────────────
|
|
142
|
+
const stopWatching = watchDir(componentsDir, (filePath, event) => {
|
|
143
|
+
const rel = path.relative(componentsDir, filePath);
|
|
144
|
+
const compName = rel.split(path.sep)[0];
|
|
145
|
+
if (!compName) return;
|
|
146
|
+
|
|
147
|
+
// Debounce — wait 300ms after last change before deploying
|
|
148
|
+
if (deployQueue.has(compName)) clearTimeout(deployQueue.get(compName));
|
|
149
|
+
deployQueue.set(compName, setTimeout(async () => {
|
|
150
|
+
deployQueue.delete(compName);
|
|
151
|
+
const compDir = path.join(componentsDir, compName);
|
|
152
|
+
if (!fs.existsSync(compDir)) return;
|
|
153
|
+
console.log(`[wh dev] 🔄 ${compName} changed — redeploying...`);
|
|
154
|
+
const result = await deployComponent(compName, compDir);
|
|
155
|
+
if (result.success) {
|
|
156
|
+
console.log(`[wh dev] ✅ ${compName} deployed in ${result.elapsed}ms`);
|
|
157
|
+
} else {
|
|
158
|
+
console.log(`[wh dev] ❌ ${compName}: ${result.error}`);
|
|
159
|
+
}
|
|
160
|
+
}, 300));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ── HTTP server ───────────────────────────────────────────────────────────
|
|
164
|
+
const server = http.createServer(async (req, res) => {
|
|
165
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
166
|
+
const p = url.pathname;
|
|
167
|
+
|
|
168
|
+
// CORS
|
|
169
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
170
|
+
|
|
171
|
+
// SSE endpoint — browser connects for hot reload
|
|
172
|
+
if (p === "/__wh_dev__/events") {
|
|
173
|
+
sse.addClient(res); return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Status
|
|
177
|
+
if (p === "/__wh_dev__/status") {
|
|
178
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
179
|
+
res.end(JSON.stringify({
|
|
180
|
+
running: true,
|
|
181
|
+
components: [...deployedComponents.keys()],
|
|
182
|
+
clients: sse.clientCount(),
|
|
183
|
+
manifestPath: manifestOut
|
|
184
|
+
})); return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Trigger manual redeploy
|
|
188
|
+
if (p === "/__wh_dev__/redeploy" && req.method === "POST") {
|
|
189
|
+
const comps = await scanComponents();
|
|
190
|
+
const results = [];
|
|
191
|
+
for (const comp of comps) {
|
|
192
|
+
const r = await deployComponent(comp.name, comp.dir);
|
|
193
|
+
results.push(r);
|
|
194
|
+
}
|
|
195
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
196
|
+
res.end(JSON.stringify({ results })); return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Serve preview page
|
|
200
|
+
if (p === "/" || p === "/index.html") {
|
|
201
|
+
const manifest = await refreshManifest().catch(() => ({ components: {} }));
|
|
202
|
+
const mounts = Object.keys(manifest.components)
|
|
203
|
+
.map(name => ` <wh-component name="${name}"></wh-component>`)
|
|
204
|
+
.join("\n");
|
|
205
|
+
|
|
206
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
207
|
+
res.end(previewPage(mounts, PORT)); return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Serve manifest
|
|
211
|
+
if (p === "/wh-manifest.json") {
|
|
212
|
+
if (fs.existsSync(manifestOut)) {
|
|
213
|
+
const data = await fs.readFile(manifestOut, "utf-8");
|
|
214
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
215
|
+
res.end(data);
|
|
216
|
+
} else {
|
|
217
|
+
res.writeHead(404); res.end("{}");
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
res.writeHead(404); res.end("Not found");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ── Initial deploy ────────────────────────────────────────────────────────
|
|
226
|
+
console.log(`[wh dev] 🔍 Scanning ${componentsDir}...`);
|
|
227
|
+
const comps = await scanComponents();
|
|
228
|
+
console.log(`[wh dev] Found ${comps.length} components — deploying...\n`);
|
|
229
|
+
|
|
230
|
+
for (const comp of comps) {
|
|
231
|
+
process.stdout.write(` ${comp.name}... `);
|
|
232
|
+
const r = await deployComponent(comp.name, comp.dir);
|
|
233
|
+
console.log(r.success ? `✅ (${r.elapsed}ms)` : `❌ ${r.error}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
server.listen(PORT, () => {
|
|
237
|
+
console.log(`\n[wh dev] 🚀 Dev server running`);
|
|
238
|
+
console.log(` Preview : http://localhost:${PORT}`);
|
|
239
|
+
console.log(` Manifest : http://localhost:${PORT}/wh-manifest.json`);
|
|
240
|
+
console.log(` Status : http://localhost:${PORT}/__wh_dev__/status`);
|
|
241
|
+
console.log(`\n[wh dev] 👀 Watching ${componentsDir} for changes...\n`);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
return { server, stopWatching };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ─── Preview page HTML ────────────────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
function previewPage(mounts, port) {
|
|
250
|
+
return `<!DOCTYPE html>
|
|
251
|
+
<html lang="en">
|
|
252
|
+
<head>
|
|
253
|
+
<meta charset="UTF-8">
|
|
254
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
255
|
+
<title>wh dev — Live Preview</title>
|
|
256
|
+
<style>
|
|
257
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
258
|
+
body{background:#030712;color:#e8eaf0;font-family:system-ui,sans-serif}
|
|
259
|
+
#wh-dev-bar{
|
|
260
|
+
position:fixed;bottom:0;left:0;right:0;z-index:9999;
|
|
261
|
+
background:#0d0e12;border-top:1px solid #1e2028;
|
|
262
|
+
padding:8px 16px;display:flex;align-items:center;gap:12px;
|
|
263
|
+
font-family:monospace;font-size:11px;
|
|
264
|
+
}
|
|
265
|
+
.dev-dot{width:7px;height:7px;border-radius:50%;background:#4ade80;animation:pulse 2s infinite}
|
|
266
|
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
|
|
267
|
+
.dev-status{color:#4ade80;font-weight:600}
|
|
268
|
+
.dev-log{color:#6b7280;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
269
|
+
.dev-reload{background:none;border:1px solid #374151;color:#9ca3af;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px;font-family:monospace}
|
|
270
|
+
.dev-reload:hover{border-color:#6366f1;color:#818cf8}
|
|
271
|
+
</style>
|
|
272
|
+
</head>
|
|
273
|
+
<body>
|
|
274
|
+
|
|
275
|
+
${mounts}
|
|
276
|
+
|
|
277
|
+
<div id="wh-dev-bar">
|
|
278
|
+
<div class="dev-dot" id="dev-dot"></div>
|
|
279
|
+
<span class="dev-status" id="dev-status">wh dev</span>
|
|
280
|
+
<span class="dev-log" id="dev-log">Connected — watching for changes</span>
|
|
281
|
+
<button class="dev-reload" onclick="location.reload()">↻ Reload</button>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<script src="https://unpkg.com/webhanger-front@latest/browser.min.js"></script>
|
|
285
|
+
<script>
|
|
286
|
+
WebHangerFront.initialize("/wh-manifest.json");
|
|
287
|
+
|
|
288
|
+
// ── Hot reload via SSE
|
|
289
|
+
var es = new EventSource("/__wh_dev__/events");
|
|
290
|
+
var dot = document.getElementById("dev-dot");
|
|
291
|
+
var status = document.getElementById("dev-status");
|
|
292
|
+
var log = document.getElementById("dev-log");
|
|
293
|
+
|
|
294
|
+
function setLog(msg, color) {
|
|
295
|
+
log.textContent = msg;
|
|
296
|
+
if (color) log.style.color = color;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
es.addEventListener("component-updated", function(e) {
|
|
300
|
+
var d = JSON.parse(e.data);
|
|
301
|
+
dot.style.background = "#fbbf24";
|
|
302
|
+
setLog("🔄 " + d.name + " updated (" + d.time + "ms) — reloading...", "#fbbf24");
|
|
303
|
+
setTimeout(function() { location.reload(); }, 500);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
es.addEventListener("deploy-error", function(e) {
|
|
307
|
+
var d = JSON.parse(e.data);
|
|
308
|
+
dot.style.background = "#f87171";
|
|
309
|
+
setLog("❌ " + d.name + ": " + d.message, "#f87171");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
es.addEventListener("manifest-updated", function(e) {
|
|
313
|
+
var d = JSON.parse(e.data);
|
|
314
|
+
setLog("📋 Manifest updated — " + d.componentCount + " components", "#60a5fa");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
es.addEventListener("dep-graph", function(e) {
|
|
318
|
+
var d = JSON.parse(e.data);
|
|
319
|
+
setLog("🔗 Dep graph: " + d.graph.join(" → "), "#c084fc");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
es.onopen = function() {
|
|
323
|
+
dot.style.background = "#4ade80";
|
|
324
|
+
status.textContent = "wh dev";
|
|
325
|
+
setLog("Connected — watching for changes", "#4ade80");
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
es.onerror = function() {
|
|
329
|
+
dot.style.background = "#f87171";
|
|
330
|
+
status.textContent = "disconnected";
|
|
331
|
+
setLog("Dev server disconnected — retrying...", "#f87171");
|
|
332
|
+
};
|
|
333
|
+
</script>
|
|
334
|
+
|
|
335
|
+
</body>
|
|
336
|
+
</html>`;
|
|
337
|
+
}
|
package/helper/analyzer.js
CHANGED
|
@@ -162,21 +162,48 @@ export async function analyzeComponent(componentDir) {
|
|
|
162
162
|
|
|
163
163
|
/**
|
|
164
164
|
* Auto-generates webhanger.component.json if it doesn't exist.
|
|
165
|
-
* If it exists, merges new assets without overwriting manual ones.
|
|
165
|
+
* If it exists, merges new assets + props without overwriting manual ones.
|
|
166
166
|
*/
|
|
167
167
|
export async function autoGenerateComponentMeta(componentDir) {
|
|
168
168
|
const metaPath = path.join(componentDir, "webhanger.component.json");
|
|
169
169
|
const analysis = await analyzeComponent(componentDir);
|
|
170
170
|
|
|
171
|
-
|
|
171
|
+
// Extract {{wh.propName}} placeholders from HTML
|
|
172
|
+
const htmlPath = path.join(componentDir, "index.html");
|
|
173
|
+
const props = {};
|
|
174
|
+
if (await fs.pathExists(htmlPath)) {
|
|
175
|
+
const html = await fs.readFile(htmlPath, "utf-8");
|
|
176
|
+
const matches = [...html.matchAll(/\{\{wh\.([a-zA-Z0-9_]+)\}\}/g)];
|
|
177
|
+
for (const m of matches) {
|
|
178
|
+
const propName = m[1];
|
|
179
|
+
if (!props[propName]) {
|
|
180
|
+
props[propName] = { type: "string", default: "" };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let existing = { assets: [], dependencies: [], props: {} };
|
|
172
186
|
if (await fs.pathExists(metaPath)) {
|
|
173
187
|
existing = await fs.readJson(metaPath);
|
|
188
|
+
if (!existing.props) existing.props = {};
|
|
174
189
|
}
|
|
175
190
|
|
|
176
|
-
// Merge — don't duplicate URLs
|
|
177
|
-
const existingUrls = new Set(existing.assets.map(a => a.url));
|
|
191
|
+
// Merge assets — don't duplicate URLs
|
|
192
|
+
const existingUrls = new Set((existing.assets || []).map(a => a.url));
|
|
178
193
|
const newAssets = analysis.assets.filter(a => !existingUrls.has(a.url));
|
|
179
|
-
|
|
194
|
+
|
|
195
|
+
// Merge props — don't overwrite existing defaults
|
|
196
|
+
const mergedProps = { ...props };
|
|
197
|
+
for (const [key, val] of Object.entries(existing.props || {})) {
|
|
198
|
+
mergedProps[key] = val; // existing props take priority
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const merged = {
|
|
202
|
+
...existing,
|
|
203
|
+
assets: [...(existing.assets || []), ...newAssets],
|
|
204
|
+
dependencies: existing.dependencies || [],
|
|
205
|
+
props: mergedProps
|
|
206
|
+
};
|
|
180
207
|
|
|
181
208
|
await fs.writeJson(metaPath, merged, { spaces: 2 });
|
|
182
209
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import crypto from "crypto";
|
|
4
|
+
|
|
5
|
+
const CONFIG_FILE = "wh-auth.config.json";
|
|
6
|
+
|
|
7
|
+
export const PROVIDERS = {
|
|
8
|
+
google: {
|
|
9
|
+
authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
10
|
+
tokenUrl: "https://oauth2.googleapis.com/token",
|
|
11
|
+
profileUrl: "https://www.googleapis.com/oauth2/v2/userinfo",
|
|
12
|
+
scopes: ["openid", "email", "profile"],
|
|
13
|
+
fields: ["id", "email", "name", "picture"]
|
|
14
|
+
},
|
|
15
|
+
github: {
|
|
16
|
+
authUrl: "https://github.com/login/oauth/authorize",
|
|
17
|
+
tokenUrl: "https://github.com/login/oauth/access_token",
|
|
18
|
+
profileUrl: "https://api.github.com/user",
|
|
19
|
+
emailUrl: "https://api.github.com/user/emails",
|
|
20
|
+
scopes: ["user:email", "read:user"],
|
|
21
|
+
fields: ["id", "email", "name", "avatar_url", "login"]
|
|
22
|
+
},
|
|
23
|
+
facebook: {
|
|
24
|
+
authUrl: "https://www.facebook.com/v18.0/dialog/oauth",
|
|
25
|
+
tokenUrl: "https://graph.facebook.com/v18.0/oauth/access_token",
|
|
26
|
+
profileUrl: "https://graph.facebook.com/me",
|
|
27
|
+
scopes: ["email", "public_profile"],
|
|
28
|
+
fields: ["id", "email", "name", "picture"]
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function loadAuthConfig(configPath = null) {
|
|
33
|
+
const candidates = [
|
|
34
|
+
configPath,
|
|
35
|
+
path.join(process.cwd(), CONFIG_FILE),
|
|
36
|
+
].filter(Boolean);
|
|
37
|
+
|
|
38
|
+
for (const c of candidates) {
|
|
39
|
+
if (fs.existsSync(c)) return fs.readJsonSync(c);
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`${CONFIG_FILE} not found. Run: wh auth init`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function saveAuthConfig(config, configPath = null) {
|
|
45
|
+
const dest = configPath || path.join(process.cwd(), CONFIG_FILE);
|
|
46
|
+
await fs.writeJson(dest, config, { spaces: 2 });
|
|
47
|
+
return dest;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function generateSessionSecret() {
|
|
51
|
+
return crypto.randomBytes(32).toString("hex");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildCallbackUrl(baseUrl, callbackPath, provider, port = null) {
|
|
55
|
+
let base = baseUrl.replace(/\/$/, "");
|
|
56
|
+
|
|
57
|
+
// If port is provided and not already in the URL, inject it
|
|
58
|
+
if (port) {
|
|
59
|
+
try {
|
|
60
|
+
const u = new URL(base);
|
|
61
|
+
if (!u.port) u.port = String(port);
|
|
62
|
+
base = u.origin;
|
|
63
|
+
} catch (_) {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const cb = callbackPath.replace(/\/$/, "");
|
|
67
|
+
return `${base}${cb}/${provider}`;
|
|
68
|
+
}
|
package/helper/bundler.js
CHANGED
|
@@ -49,13 +49,14 @@ export async function bundle(componentDir, projectId) {
|
|
|
49
49
|
const encryptChunk = (content, salt) => encrypt(content, projectId, salt);
|
|
50
50
|
|
|
51
51
|
const payload = {
|
|
52
|
-
v: 2,
|
|
52
|
+
v: 2,
|
|
53
53
|
h: encryptChunk(html, "::html"),
|
|
54
54
|
c: encryptChunk(css, "::css"),
|
|
55
55
|
j: encryptChunk(js, "::js"),
|
|
56
56
|
assets: meta.assets || [],
|
|
57
57
|
dependencies: meta.dependencies || [],
|
|
58
|
-
|
|
58
|
+
props: meta.props || {},
|
|
59
|
+
integrity: integrityHash(html + css + js)
|
|
59
60
|
};
|
|
60
61
|
|
|
61
62
|
return JSON.stringify(payload);
|
package/helper/dbHandler.js
CHANGED
|
@@ -31,10 +31,17 @@ function getFirestore(serviceAccountPath) {
|
|
|
31
31
|
|
|
32
32
|
async function firebaseRegister(db, projectId, meta) {
|
|
33
33
|
const { name, version, cdnUrl, token, expires, dependencies = [] } = meta;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
|
|
35
|
+
// Ensure parent project document exists (required for Firestore queries)
|
|
36
|
+
const projectRef = db.collection("projects").doc(projectId);
|
|
37
|
+
await projectRef.set({ projectId, updatedAt: admin.firestore.FieldValue.serverTimestamp() }, { merge: true });
|
|
38
|
+
|
|
39
|
+
// Ensure component document exists
|
|
40
|
+
const compRef = projectRef.collection("components").doc(name);
|
|
41
|
+
await compRef.set({ name, updatedAt: admin.firestore.FieldValue.serverTimestamp() }, { merge: true });
|
|
42
|
+
|
|
43
|
+
// Write version
|
|
44
|
+
await compRef.collection("versions").doc(version)
|
|
38
45
|
.set({ name, version, cdnUrl, token, expires, dependencies, createdAt: admin.firestore.FieldValue.serverTimestamp() });
|
|
39
46
|
}
|
|
40
47
|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import { PROVIDERS } from "./authConfig.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build the OAuth redirect URL for a provider.
|
|
6
|
+
*/
|
|
7
|
+
export function buildAuthUrl(provider, config, state) {
|
|
8
|
+
const p = PROVIDERS[provider];
|
|
9
|
+
const providerConfig = config.providers[provider];
|
|
10
|
+
if (!p || !providerConfig) throw new Error(`Provider "${provider}" not configured.`);
|
|
11
|
+
|
|
12
|
+
const params = new URLSearchParams({
|
|
13
|
+
client_id: providerConfig.clientId,
|
|
14
|
+
redirect_uri: providerConfig.callbackUrl,
|
|
15
|
+
response_type: "code",
|
|
16
|
+
scope: p.scopes.join(" "),
|
|
17
|
+
state
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (provider === "google") params.set("access_type", "offline");
|
|
21
|
+
|
|
22
|
+
return `${p.authUrl}?${params.toString()}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Exchange authorization code for access token.
|
|
27
|
+
*/
|
|
28
|
+
export async function exchangeCode(provider, code, config) {
|
|
29
|
+
const p = PROVIDERS[provider];
|
|
30
|
+
const providerConfig = config.providers[provider];
|
|
31
|
+
|
|
32
|
+
const body = new URLSearchParams({
|
|
33
|
+
client_id: providerConfig.clientId,
|
|
34
|
+
client_secret: providerConfig.clientSecret,
|
|
35
|
+
code,
|
|
36
|
+
redirect_uri: providerConfig.callbackUrl,
|
|
37
|
+
grant_type: "authorization_code"
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const res = await fetch(p.tokenUrl, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" },
|
|
43
|
+
body: body.toString()
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const data = await res.json();
|
|
47
|
+
if (data.error) throw new Error(`Token exchange failed: ${data.error_description || data.error}`);
|
|
48
|
+
return data.access_token;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Fetch user profile from provider.
|
|
53
|
+
*/
|
|
54
|
+
export async function fetchProfile(provider, accessToken) {
|
|
55
|
+
const p = PROVIDERS[provider];
|
|
56
|
+
|
|
57
|
+
const res = await fetch(
|
|
58
|
+
provider === "facebook"
|
|
59
|
+
? `${p.profileUrl}?fields=${p.fields.join(",")}&access_token=${accessToken}`
|
|
60
|
+
: p.profileUrl,
|
|
61
|
+
{ headers: { Authorization: `Bearer ${accessToken}`, "User-Agent": "WebHanger-Auth/1.0" } }
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const profile = await res.json();
|
|
65
|
+
|
|
66
|
+
// GitHub: email may need separate request
|
|
67
|
+
if (provider === "github" && !profile.email) {
|
|
68
|
+
const emailRes = await fetch(p.emailUrl, {
|
|
69
|
+
headers: { Authorization: `Bearer ${accessToken}`, "User-Agent": "WebHanger-Auth/1.0" }
|
|
70
|
+
});
|
|
71
|
+
const emails = await emailRes.json();
|
|
72
|
+
const primary = emails.find(e => e.primary && e.verified);
|
|
73
|
+
if (primary) profile.email = primary.email;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Normalize profile across providers
|
|
77
|
+
return normalizeProfile(provider, profile);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeProfile(provider, raw) {
|
|
81
|
+
switch (provider) {
|
|
82
|
+
case "google":
|
|
83
|
+
return {
|
|
84
|
+
id: raw.id,
|
|
85
|
+
email: raw.email,
|
|
86
|
+
name: raw.name,
|
|
87
|
+
avatar: raw.picture,
|
|
88
|
+
provider: "google"
|
|
89
|
+
};
|
|
90
|
+
case "github":
|
|
91
|
+
return {
|
|
92
|
+
id: String(raw.id),
|
|
93
|
+
email: raw.email || "",
|
|
94
|
+
name: raw.name || raw.login,
|
|
95
|
+
avatar: raw.avatar_url,
|
|
96
|
+
username: raw.login,
|
|
97
|
+
provider: "github"
|
|
98
|
+
};
|
|
99
|
+
case "facebook":
|
|
100
|
+
return {
|
|
101
|
+
id: raw.id,
|
|
102
|
+
email: raw.email || "",
|
|
103
|
+
name: raw.name,
|
|
104
|
+
avatar: raw.picture?.data?.url || "",
|
|
105
|
+
provider: "facebook"
|
|
106
|
+
};
|
|
107
|
+
default:
|
|
108
|
+
return { ...raw, provider };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Issue a signed JWT for the user.
|
|
114
|
+
*/
|
|
115
|
+
export function issueJWT(user, sessionSecret, expiresIn = "7d") {
|
|
116
|
+
const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
|
117
|
+
const exp = Math.floor(Date.now() / 1000) + parseDuration(expiresIn);
|
|
118
|
+
const payload = btoa(JSON.stringify({ ...user, exp, iat: Math.floor(Date.now() / 1000) }));
|
|
119
|
+
const sig = crypto.createHmac("sha256", sessionSecret)
|
|
120
|
+
.update(`${header}.${payload}`).digest("base64url");
|
|
121
|
+
return `${header}.${payload}.${sig}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Verify and decode a JWT.
|
|
126
|
+
*/
|
|
127
|
+
export function verifyJWT(token, sessionSecret) {
|
|
128
|
+
const [header, payload, sig] = token.split(".");
|
|
129
|
+
const expected = crypto.createHmac("sha256", sessionSecret)
|
|
130
|
+
.update(`${header}.${payload}`).digest("base64url");
|
|
131
|
+
if (sig !== expected) throw new Error("Invalid token signature.");
|
|
132
|
+
const data = JSON.parse(Buffer.from(payload, "base64").toString("utf-8"));
|
|
133
|
+
if (data.exp && Date.now() / 1000 > data.exp) throw new Error("Token expired.");
|
|
134
|
+
return data;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function parseDuration(str) {
|
|
138
|
+
const units = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
139
|
+
const match = str.match(/^(\d+)([smhd])$/);
|
|
140
|
+
if (!match) return 604800; // default 7d
|
|
141
|
+
return parseInt(match[1]) * (units[match[2]] || 1);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Generate a random state token for CSRF protection.
|
|
146
|
+
*/
|
|
147
|
+
export function generateState() {
|
|
148
|
+
return crypto.randomBytes(16).toString("hex");
|
|
149
|
+
}
|