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.
@@ -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
+ }
@@ -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
- let existing = { assets: [] };
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
- const merged = { ...existing, assets: [...existing.assets, ...newAssets] };
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, // version 2 = AES encrypted
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
- integrity: integrityHash(html + css + js) // tamper detection
58
+ props: meta.props || {},
59
+ integrity: integrityHash(html + css + js)
59
60
  };
60
61
 
61
62
  return JSON.stringify(payload);
@@ -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
- await db
35
- .collection("projects").doc(projectId)
36
- .collection("components").doc(name)
37
- .collection("versions").doc(version)
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
+ }