webhanger 1.0.6 ā 1.0.9
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 +462 -321
- package/bin/cli.js +266 -71
- 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
package/bin/cli.js
CHANGED
|
@@ -345,70 +345,6 @@ switch (command) {
|
|
|
345
345
|
console.log(chalk.gray(` "cdn": { "url": "https://webhanger-edge.your-subdomain.workers.dev" }`));
|
|
346
346
|
break;
|
|
347
347
|
}
|
|
348
|
-
case "release": {
|
|
349
|
-
const releaseType = args[1] || "patch"; // patch | minor | major
|
|
350
|
-
const { execSync } = await import("child_process");
|
|
351
|
-
const { default: fsExtra } = await import("fs-extra");
|
|
352
|
-
const { default: pathMod } = await import("path");
|
|
353
|
-
|
|
354
|
-
console.log(chalk.cyan(`\nš Releasing webhanger + webhanger-front (${releaseType})...\n`));
|
|
355
|
-
|
|
356
|
-
try {
|
|
357
|
-
// 1. Bump + publish webhanger
|
|
358
|
-
console.log(chalk.white(" [1/4] Publishing webhanger..."));
|
|
359
|
-
execSync(`npm version ${releaseType} --no-git-tag-version`, { stdio: "inherit" });
|
|
360
|
-
execSync("npm publish --access public", { stdio: "inherit" });
|
|
361
|
-
const whPkg = await fsExtra.readJson("./package.json");
|
|
362
|
-
console.log(chalk.green(` ā
webhanger@${whPkg.version} published`));
|
|
363
|
-
|
|
364
|
-
// 2. Build + bump + publish webhanger-front
|
|
365
|
-
console.log(chalk.white("\n [2/4] Building webhanger-front..."));
|
|
366
|
-
execSync("npm run build", { cwd: "./webhanger-front", stdio: "inherit" });
|
|
367
|
-
execSync(`npm version ${releaseType} --no-git-tag-version`, { cwd: "./webhanger-front", stdio: "inherit" });
|
|
368
|
-
execSync("npm publish --access public", { cwd: "./webhanger-front", stdio: "inherit" });
|
|
369
|
-
const whfPkg = await fsExtra.readJson("./webhanger-front/package.json");
|
|
370
|
-
console.log(chalk.green(` ā
webhanger-front@${whfPkg.version} published`));
|
|
371
|
-
|
|
372
|
-
// 3. Update all HTML files to use new unpkg version
|
|
373
|
-
console.log(chalk.white("\n [3/4] Updating unpkg references..."));
|
|
374
|
-
const newUrl = `https://unpkg.com/webhanger-front@${whfPkg.version}/browser.min.js`;
|
|
375
|
-
const oldPattern = /https:\/\/unpkg\.com\/webhanger-front@[^/]+\/browser(?:\.min)?\.js/g;
|
|
376
|
-
|
|
377
|
-
const htmlFiles = [];
|
|
378
|
-
async function findHtml(dir) {
|
|
379
|
-
const entries = await fsExtra.readdir(dir, { withFileTypes: true });
|
|
380
|
-
for (const e of entries) {
|
|
381
|
-
if (e.name === "node_modules" || e.name === ".git") continue;
|
|
382
|
-
const full = pathMod.join(dir, e.name);
|
|
383
|
-
if (e.isDirectory()) await findHtml(full);
|
|
384
|
-
else if (e.name.endsWith(".html") || e.name.endsWith(".js")) htmlFiles.push(full);
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
await findHtml(".");
|
|
388
|
-
|
|
389
|
-
let updated = 0;
|
|
390
|
-
for (const file of htmlFiles) {
|
|
391
|
-
const content = await fsExtra.readFile(file, "utf-8");
|
|
392
|
-
if (oldPattern.test(content)) {
|
|
393
|
-
await fsExtra.writeFile(file, content.replace(oldPattern, newUrl), "utf-8");
|
|
394
|
-
console.log(chalk.gray(` ā ${file}`));
|
|
395
|
-
updated++;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
console.log(chalk.green(` ā
Updated ${updated} files to ${newUrl}`));
|
|
399
|
-
|
|
400
|
-
// 4. Summary
|
|
401
|
-
console.log(chalk.green(`\nā
Release complete!`));
|
|
402
|
-
console.log(chalk.white(` webhanger : v${whPkg.version}`));
|
|
403
|
-
console.log(chalk.white(` webhanger-front : v${whfPkg.version}`));
|
|
404
|
-
console.log(chalk.gray(` unpkg URL : ${newUrl}\n`));
|
|
405
|
-
|
|
406
|
-
} catch (err) {
|
|
407
|
-
console.log(chalk.red(`\nā Release failed: ${err.message}`));
|
|
408
|
-
process.exit(1);
|
|
409
|
-
}
|
|
410
|
-
break;
|
|
411
|
-
}
|
|
412
348
|
case "atomize": {
|
|
413
349
|
const atomFile = args[1];
|
|
414
350
|
const atomOut = args[2] || "./atomized";
|
|
@@ -460,11 +396,9 @@ switch (command) {
|
|
|
460
396
|
const manifestPath = pathMod.join(pathMod.dirname(atomFile), "wh-manifest.json");
|
|
461
397
|
await fsExtra.writeJson(manifestPath, manifest, { spaces: 2 });
|
|
462
398
|
|
|
463
|
-
// Step 4: Write globalJs to
|
|
399
|
+
// Step 4: Write globalJs to separate file
|
|
464
400
|
const globalJsFile = pathMod.join(pathMod.dirname(atomFile), "wh-global.js");
|
|
465
|
-
if (globalJs)
|
|
466
|
-
await fsExtra.writeFile(globalJsFile, globalJs, "utf-8");
|
|
467
|
-
}
|
|
401
|
+
if (globalJs) await fsExtra.writeFile(globalJsFile, globalJs, "utf-8");
|
|
468
402
|
|
|
469
403
|
// Step 5: Generate production-ready CDN-powered HTML
|
|
470
404
|
const mounts = components.map(c => ` <wh-component name="${c.name}"></wh-component>`).join("\n");
|
|
@@ -475,15 +409,24 @@ switch (command) {
|
|
|
475
409
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
476
410
|
<title>WebHanger</title>
|
|
477
411
|
${cdnAssets.filter(a => a.type === "style").map(a => `<link rel="stylesheet" href="${a.url}">`).join("\n ")}
|
|
412
|
+
<script src="https://unpkg.com/webhanger-front@latest/browser.min.js"></script>
|
|
478
413
|
</head>
|
|
479
414
|
<body>
|
|
480
415
|
${mounts}
|
|
481
416
|
${cdnAssets.filter(a => a.type === "script").map(a => `<script src="${a.url}"></script>`).join("\n ")}
|
|
482
|
-
<script src="https://unpkg.com/webhanger-front@latest/browser.min.js"></script>
|
|
483
417
|
<script>
|
|
418
|
+
var totalComponents = document.querySelectorAll("wh-component").length;
|
|
419
|
+
var mountedCount = 0;
|
|
420
|
+
WebHangerFront.on("afterMount", function() {
|
|
421
|
+
mountedCount++;
|
|
422
|
+
if (mountedCount >= totalComponents && !window.__whGlobalRan) {
|
|
423
|
+
window.__whGlobalRan = true;
|
|
424
|
+
${globalJs ? `var s = document.createElement("script"); s.src = "./wh-global.js"; document.body.appendChild(s);` : ""}
|
|
425
|
+
}
|
|
426
|
+
});
|
|
484
427
|
WebHangerFront.initialize("./wh-manifest.json");
|
|
485
428
|
</script>
|
|
486
|
-
${globalJs ?
|
|
429
|
+
${globalJs ? "" : ""}
|
|
487
430
|
</body>
|
|
488
431
|
</html>`;
|
|
489
432
|
|
|
@@ -517,6 +460,259 @@ ${mounts}
|
|
|
517
460
|
}
|
|
518
461
|
break;
|
|
519
462
|
}
|
|
463
|
+
case "personalize": {
|
|
464
|
+
const { default: fsExtra } = await import("fs-extra");
|
|
465
|
+
const { default: pathMod } = await import("path");
|
|
466
|
+
|
|
467
|
+
const subCmd = args[1];
|
|
468
|
+
|
|
469
|
+
if (subCmd === "init") {
|
|
470
|
+
// Scaffold a wh-personalization.json
|
|
471
|
+
const example = {
|
|
472
|
+
hero: {
|
|
473
|
+
rules: [
|
|
474
|
+
{ if: { country: "IN" }, component: "hero-india" },
|
|
475
|
+
{ if: { country: "US" }, component: "hero-us" },
|
|
476
|
+
{ if: { device: "mobile" }, component: "hero-mobile" },
|
|
477
|
+
{ if: { role: "premium" }, component: "hero-premium" },
|
|
478
|
+
{ if: { abTest: "variant-b" }, component: "hero-b" }
|
|
479
|
+
],
|
|
480
|
+
default: "hero"
|
|
481
|
+
},
|
|
482
|
+
navbar: {
|
|
483
|
+
rules: [
|
|
484
|
+
{ if: { role: "admin" }, component: "navbar-admin" },
|
|
485
|
+
{ if: { plan: "premium" }, component: "navbar-premium" }
|
|
486
|
+
],
|
|
487
|
+
default: "navbar"
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
const dest = pathMod.join(process.cwd(), "wh-personalization.json");
|
|
491
|
+
await fsExtra.writeJson(dest, example, { spaces: 2 });
|
|
492
|
+
console.log(chalk.green("\nā
wh-personalization.json created"));
|
|
493
|
+
console.log(chalk.gray(" Edit the rules to match your use case."));
|
|
494
|
+
console.log(chalk.gray(" Supported conditions: country, device, role, abTest, lang, hour, plan\n"));
|
|
495
|
+
|
|
496
|
+
} else if (subCmd === "test") {
|
|
497
|
+
// Test rule resolution against current context
|
|
498
|
+
const { resolveAll } = await import("../helper/personalization.js");
|
|
499
|
+
const configPath = args[2] || "./wh-personalization.json";
|
|
500
|
+
if (!fsExtra.existsSync(configPath)) {
|
|
501
|
+
console.log(chalk.red(`${configPath} not found. Run: wh personalize init`));
|
|
502
|
+
process.exit(1);
|
|
503
|
+
}
|
|
504
|
+
const config = await fsExtra.readJson(configPath);
|
|
505
|
+
const country = args[3] || "US";
|
|
506
|
+
const device = args[4] || "desktop";
|
|
507
|
+
const role = args[5] || null;
|
|
508
|
+
|
|
509
|
+
const { resolved, ctx } = resolveAll(config, { country, device, role });
|
|
510
|
+
|
|
511
|
+
console.log(chalk.cyan("\nšÆ Personalization Resolution\n"));
|
|
512
|
+
console.log(chalk.white("Context:"));
|
|
513
|
+
console.log(chalk.gray(` country : ${ctx.country || "ā"}`));
|
|
514
|
+
console.log(chalk.gray(` device : ${ctx.device}`));
|
|
515
|
+
console.log(chalk.gray(` role : ${ctx.role || "ā"}`));
|
|
516
|
+
console.log(chalk.gray(` abTest : ${ctx.abTest}`));
|
|
517
|
+
console.log(chalk.gray(` lang : ${ctx.lang}`));
|
|
518
|
+
console.log(chalk.gray(` plan : ${ctx.plan}`));
|
|
519
|
+
console.log(chalk.white("\nResolved components:"));
|
|
520
|
+
for (const [slot, comp] of Object.entries(resolved)) {
|
|
521
|
+
console.log(chalk.gray(` ${slot.padEnd(15)} ā ${comp}`));
|
|
522
|
+
}
|
|
523
|
+
console.log();
|
|
524
|
+
|
|
525
|
+
} else {
|
|
526
|
+
console.log(chalk.white("wh personalize commands:"));
|
|
527
|
+
console.log(chalk.gray(" wh personalize init ā scaffold wh-personalization.json"));
|
|
528
|
+
console.log(chalk.gray(" wh personalize test [config] [country] [device] [role] ā test rule resolution"));
|
|
529
|
+
}
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
case "auth": {
|
|
533
|
+
const authSubCmd = args[1];
|
|
534
|
+
|
|
535
|
+
if (authSubCmd === "init") {
|
|
536
|
+
// āā wh auth init āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
537
|
+
console.log(chalk.cyan("\nš WebHanger Auth Setup\n"));
|
|
538
|
+
|
|
539
|
+
const { loadAuthConfig: _l, saveAuthConfig, generateSessionSecret, buildCallbackUrl, PROVIDERS } = await import("../helper/authConfig.js");
|
|
540
|
+
|
|
541
|
+
const providerChoices = [
|
|
542
|
+
{ name: "Google", value: "google", checked: true },
|
|
543
|
+
{ name: "GitHub", value: "github", checked: true },
|
|
544
|
+
{ name: "Facebook", value: "facebook", checked: false }
|
|
545
|
+
];
|
|
546
|
+
|
|
547
|
+
const answers = await inquirer.prompt([
|
|
548
|
+
{
|
|
549
|
+
type: "checkbox",
|
|
550
|
+
name: "providers",
|
|
551
|
+
message: "Select OAuth providers:",
|
|
552
|
+
choices: providerChoices,
|
|
553
|
+
validate: v => v.length > 0 || "Select at least one provider"
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
type: "input",
|
|
557
|
+
name: "baseUrl",
|
|
558
|
+
message: "Your app base URL (e.g. https://myapp.com):",
|
|
559
|
+
validate: v => v.startsWith("http") || "Must be a valid URL"
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
type: "input",
|
|
563
|
+
name: "callbackPath",
|
|
564
|
+
message: "OAuth callback path:",
|
|
565
|
+
default: "/auth/callback"
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
type: "input",
|
|
569
|
+
name: "port",
|
|
570
|
+
message: "Auth server port:",
|
|
571
|
+
default: "3001"
|
|
572
|
+
}
|
|
573
|
+
]);
|
|
574
|
+
|
|
575
|
+
const config = {
|
|
576
|
+
baseUrl: answers.baseUrl.replace(/\/$/, ""),
|
|
577
|
+
callbackPath: answers.callbackPath,
|
|
578
|
+
port: parseInt(answers.port),
|
|
579
|
+
providers: {},
|
|
580
|
+
session: {
|
|
581
|
+
secret: generateSessionSecret(),
|
|
582
|
+
expiresIn: "7d"
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
// Collect credentials per provider
|
|
587
|
+
for (const provider of answers.providers) {
|
|
588
|
+
console.log(chalk.cyan(`\nāļø Configure ${provider.charAt(0).toUpperCase() + provider.slice(1)}`));
|
|
589
|
+
const cbUrl = buildCallbackUrl(answers.baseUrl, answers.callbackPath, provider, answers.port);
|
|
590
|
+
console.log(chalk.gray(` Callback URL: ${cbUrl}`));
|
|
591
|
+
|
|
592
|
+
const links = {
|
|
593
|
+
google: "https://console.cloud.google.com/apis/credentials",
|
|
594
|
+
github: "https://github.com/settings/developers",
|
|
595
|
+
facebook: "https://developers.facebook.com/apps"
|
|
596
|
+
};
|
|
597
|
+
console.log(chalk.gray(` Create app at: ${links[provider]}\n`));
|
|
598
|
+
|
|
599
|
+
const creds = await inquirer.prompt([
|
|
600
|
+
{ type: "input", name: "clientId", message: `${provider} Client ID:` },
|
|
601
|
+
{ type: "password", name: "clientSecret", message: `${provider} Client Secret:` }
|
|
602
|
+
]);
|
|
603
|
+
|
|
604
|
+
config.providers[provider] = {
|
|
605
|
+
clientId: creds.clientId,
|
|
606
|
+
clientSecret: creds.clientSecret,
|
|
607
|
+
scopes: PROVIDERS[provider].scopes,
|
|
608
|
+
callbackUrl: cbUrl
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const dest = await saveAuthConfig(config);
|
|
613
|
+
console.log(chalk.green(`\nā
wh-auth.config.json created`));
|
|
614
|
+
console.log(chalk.white("\nš Add these callback URLs to your OAuth apps:"));
|
|
615
|
+
for (const [p, c] of Object.entries(config.providers)) {
|
|
616
|
+
console.log(chalk.gray(` ${p.padEnd(10)} ā ${c.callbackUrl}`));
|
|
617
|
+
}
|
|
618
|
+
console.log(chalk.cyan(`\nā¶ Start auth server: wh auth serve\n`));
|
|
619
|
+
|
|
620
|
+
} else if (authSubCmd === "serve") {
|
|
621
|
+
// āā wh auth serve āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
622
|
+
const { loadAuthConfig } = await import("../helper/authConfig.js");
|
|
623
|
+
const { buildAuthUrl, exchangeCode, fetchProfile, issueJWT, generateState } = await import("../helper/oauthHandler.js");
|
|
624
|
+
const http = await import("http");
|
|
625
|
+
|
|
626
|
+
const authConfig = loadAuthConfig();
|
|
627
|
+
const PORT = args[2] ? parseInt(args[2]) : authConfig.port || 3001;
|
|
628
|
+
const states = new Map(); // CSRF state store
|
|
629
|
+
|
|
630
|
+
const server = http.default.createServer(async (req, res) => {
|
|
631
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
632
|
+
const p = url.pathname;
|
|
633
|
+
|
|
634
|
+
// āā Initiate OAuth āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
635
|
+
const initMatch = p.match(/^\/auth\/([a-z]+)$/);
|
|
636
|
+
if (initMatch) {
|
|
637
|
+
const provider = initMatch[1];
|
|
638
|
+
if (!authConfig.providers[provider]) { res.writeHead(404); res.end("Provider not configured"); return; }
|
|
639
|
+
const state = generateState();
|
|
640
|
+
states.set(state, { provider, ts: Date.now() });
|
|
641
|
+
const authUrl = buildAuthUrl(provider, authConfig, state);
|
|
642
|
+
res.writeHead(302, { Location: authUrl });
|
|
643
|
+
res.end(); return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// āā OAuth callback āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
647
|
+
const cbMatch = p.match(/^\/auth\/callback\/([a-z]+)$/);
|
|
648
|
+
if (cbMatch) {
|
|
649
|
+
const provider = cbMatch[1];
|
|
650
|
+
const code = url.searchParams.get("code");
|
|
651
|
+
const state = url.searchParams.get("state");
|
|
652
|
+
|
|
653
|
+
if (!states.has(state)) { res.writeHead(400); res.end("Invalid state"); return; }
|
|
654
|
+
states.delete(state);
|
|
655
|
+
|
|
656
|
+
try {
|
|
657
|
+
const accessToken = await exchangeCode(provider, code, authConfig);
|
|
658
|
+
const user = await fetchProfile(provider, accessToken);
|
|
659
|
+
const jwt = issueJWT(user, authConfig.session.secret, authConfig.session.expiresIn);
|
|
660
|
+
|
|
661
|
+
// Return HTML that posts token back to opener or redirects
|
|
662
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
663
|
+
res.end(`<!DOCTYPE html><html><body><script>
|
|
664
|
+
var user = ${JSON.stringify(user)};
|
|
665
|
+
var token = "${jwt}";
|
|
666
|
+
localStorage.setItem("wh_auth_token", token);
|
|
667
|
+
localStorage.setItem("wh_auth_user", JSON.stringify(user));
|
|
668
|
+
if (window.opener) {
|
|
669
|
+
window.opener.postMessage({ type: "WH_AUTH_SUCCESS", user, token }, "*");
|
|
670
|
+
window.close();
|
|
671
|
+
} else {
|
|
672
|
+
var redirect = sessionStorage.getItem("wh_auth_redirect") || "/";
|
|
673
|
+
window.location.href = redirect.replace("{{email}}", encodeURIComponent(user.email)).replace("{{name}}", encodeURIComponent(user.name));
|
|
674
|
+
}
|
|
675
|
+
</script></body></html>`);
|
|
676
|
+
} catch (err) {
|
|
677
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
678
|
+
res.end(`<!DOCTYPE html><html><body><script>
|
|
679
|
+
if (window.opener) {
|
|
680
|
+
window.opener.postMessage({ type: "WH_AUTH_ERROR", message: "${err.message}" }, "*");
|
|
681
|
+
window.close();
|
|
682
|
+
} else { document.body.innerText = "Auth error: ${err.message}"; }
|
|
683
|
+
</script></body></html>`);
|
|
684
|
+
}
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// āā Health check āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
689
|
+
if (p === "/health") {
|
|
690
|
+
res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
|
|
691
|
+
res.end(JSON.stringify({ ok: true, providers: Object.keys(authConfig.providers) }));
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
res.writeHead(404); res.end("Not found");
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
server.listen(PORT, () => {
|
|
699
|
+
console.log(chalk.cyan(`\nš WebHanger Auth Server\n`));
|
|
700
|
+
console.log(chalk.white(` Listening : http://localhost:${PORT}`));
|
|
701
|
+
console.log(chalk.gray(` Providers : ${Object.keys(authConfig.providers).join(", ")}`));
|
|
702
|
+
console.log(chalk.gray(`\n OAuth URLs:`));
|
|
703
|
+
for (const provider of Object.keys(authConfig.providers)) {
|
|
704
|
+
console.log(chalk.gray(` http://localhost:${PORT}/auth/${provider}`));
|
|
705
|
+
}
|
|
706
|
+
console.log();
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
} else {
|
|
710
|
+
console.log(chalk.white("wh auth commands:"));
|
|
711
|
+
console.log(chalk.gray(" wh auth init ā setup OAuth providers"));
|
|
712
|
+
console.log(chalk.gray(" wh auth serve [port] ā start OAuth callback server"));
|
|
713
|
+
}
|
|
714
|
+
break;
|
|
715
|
+
}
|
|
520
716
|
case "access": {
|
|
521
717
|
const subCmd = args[1]; // grant | revoke | list
|
|
522
718
|
const loadConfigFn = (await import("../helper/loadConfig.js")).default;
|
|
@@ -924,7 +1120,6 @@ ${mounts}
|
|
|
924
1120
|
console.log(chalk.cyan(BANNER));
|
|
925
1121
|
console.log(chalk.white("Commands:"));
|
|
926
1122
|
console.log(chalk.gray(" wh init ā setup your project"));
|
|
927
|
-
console.log(chalk.gray(" wh release [patch|minor|major] ā publish both packages + update all unpkg refs"));
|
|
928
1123
|
console.log(chalk.gray(" wh ship <comp-dir> <site-dir> [version] [out-dir] ā deploy + build + zip in one shot"));
|
|
929
1124
|
console.log(chalk.gray(" wh deploy <dir> <name> <version> ā deploy a single component"));
|
|
930
1125
|
console.log(chalk.gray(" wh graph-deploy <comp-dir> [version] [out-dir] ā deploy all + resolve dep graph"));
|
|
@@ -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
|
+
}
|