@rblez/authly 0.4.0 → 0.5.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.
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Authly Hono server — runs both locally (dashboard) and on Railway (API only).
3
+ *
4
+ * Local: serves dist/dashboard/ static files + all /api/* routes
5
+ * Railway: serves only /api/* routes + /mcp (no HTML, all JSON)
6
+ */
7
+
1
8
  import { Hono } from "hono";
2
9
  import { serve } from "@hono/node-server";
3
10
  import fs from "node:fs";
@@ -6,10 +13,21 @@ import { fileURLToPath } from "node:url";
6
13
  import { createHash, randomBytes } from "node:crypto";
7
14
  import chalk from "chalk";
8
15
  import ora from "ora";
16
+ import { scanSupabase } from "../integrations/supabase.js";
9
17
  import { getSupabaseClient, fetchUsers } from "../lib/supabase.js";
10
- import { createSessionToken, verifySessionToken, authMiddleware, requireRole } from "../lib/jwt.js";
11
- import { signUp, signIn, signOut, getSession, getProviders, handleOAuthCallback, sendMagicLink, verifyMagicLink } from "../auth/index.js";
12
- import { buildAuthorizeUrl, exchangeTokens, listProviderStatus } from "../lib/oauth.js";
18
+ import { createSessionToken, verifySessionToken, authMiddleware } from "../lib/jwt.js";
19
+ import {
20
+ buildAuthorizeUrl,
21
+ exchangeTokens,
22
+ upsertUser,
23
+ listProviderStatus,
24
+ } from "../lib/oauth.js";
25
+ import {
26
+ buildSupabaseAuthorizeUrl,
27
+ exchangeSupabaseToken,
28
+ saveSupabaseTokens,
29
+ } from "../lib/supabase-oauth.js";
30
+ import { getProjects, getProjectApiKeys, ensureValidAccessToken } from "../lib/supabase-api.js";
13
31
  import {
14
32
  createRole,
15
33
  assignRoleToUser,
@@ -18,41 +36,51 @@ import {
18
36
  listRoles,
19
37
  } from "../generators/roles.js";
20
38
  import { listMigrations, getMigration, migrations } from "../generators/migrations.js";
21
- import { detectFramework } from "../lib/framework.js";
22
39
  import { scaffoldAuth, previewGenerated } from "../generators/ui.js";
23
- import { generateEnv } from "../generators/env.js";
24
40
  import { mountMcp } from "../mcp/server.js";
25
- import { scanSupabase } from "../integrations/supabase.js";
26
- import { generatePKCE, buildSupabaseAuthorizeUrl, exchangeSupabaseToken, saveSupabaseTokens } from "../lib/supabase-oauth.js";
27
- import { ensureValidAccessToken, autoConfigureFromToken, getProjects, getProjectApiKeys } from "../lib/supabase-api.js";
28
41
 
29
42
  const PORT = process.env.PORT || process.env.AUTHLY_PORT || 1284;
30
43
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
44
 
32
- /**
33
- * In-memory PKCE state store (server-side, dev-only).
34
- * Key: state token, Value: { verifier, codeChallenge }
35
- * Survives no restart — fine for local dev.
36
- */
45
+ /** In-memory PKCE state for Supabase OAuth (server-side, dev-only). */
37
46
  const pkceState = new Map();
38
47
 
48
+ /** Provider test handlers keyed by provider name. */
49
+ async function _testProvider(name) {
50
+ const id = process.env[`${name.toUpperCase()}_CLIENT_ID`];
51
+ const secret = process.env[`${name.toUpperCase()}_CLIENT_SECRET`];
52
+ if (!id || !secret) return { valid: false, error: "Credentials not configured" };
53
+ // Validate by attempting an OAuth token exchange (provider-specific endpoints)
54
+ const endpoints = {
55
+ google: "https://oauth2.googleapis.com/token",
56
+ github: "https://github.com/login/oauth/access_token",
57
+ discord: "https://discord.com/api/oauth2/token",
58
+ };
59
+ const url = endpoints[name];
60
+ if (!url) return { valid: false, error: `Unknown provider: ${name}` };
61
+ try {
62
+ await fetch(url, { method: "POST" });
63
+ return { valid: true };
64
+ } catch (e) {
65
+ return { valid: false, error: e.message };
66
+ }
67
+ }
68
+
39
69
  export async function cmdServe() {
40
70
  const spinner = ora("Starting authly dashboard…").start();
41
71
 
42
72
  const app = new Hono();
43
73
 
44
- // ── CORS — allow localhost to call the hosted API ──
45
- app.use("/api/*", (c) => {
74
+ // ── CORS — allow localhost to call hosted API ──
75
+ app.use("/api/*", async (c, next) => {
46
76
  c.header("Access-Control-Allow-Origin", "*");
47
77
  c.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
48
78
  c.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
49
- if (c.req.method === "OPTIONS") {
50
- return new Response(null, { status: 204 });
51
- }
52
- return c.next();
79
+ if (c.req.method === "OPTIONS") return new Response(null, { status: 204 });
80
+ return next();
53
81
  });
54
82
 
55
- // ── Static file serving ────────────────────────────
83
+ // ── Static file serving (local only — Railway has no dashboard) ──
56
84
  const dashboardPath = path.join(__dirname, "../../dist/dashboard");
57
85
  const hasDashboard = fs.existsSync(dashboardPath);
58
86
 
@@ -67,140 +95,139 @@ export async function cmdServe() {
67
95
  });
68
96
  }
69
97
 
70
- // ── Public API ─────────────────────────────────────
98
+ // ── Public ──────────────────────────────────────────
71
99
  app.get("/api/health", (c) => c.json({ status: "ok", uptime: process.uptime() }));
72
100
 
73
- /** GET /api/integrations/supabase/scan — scan local project */
74
- app.get("/api/integrations/supabase/scan", (c) => {
75
- const projectRoot = path.resolve(process.cwd());
76
- const scan = scanSupabase(projectRoot);
77
- return c.json({ success: true, ...scan });
78
- });
101
+ // ── Supabase ────────────────────────────────────────
79
102
 
80
- /** GET /api/userslist all users from Supabase */
81
- app.get("/api/users", async (c) => {
82
- const { client, errors } = getSupabaseClient();
83
- if (!client) return c.json({ success: false, errors }, 503);
84
- const { users, error } = await fetchUsers(client);
85
- if (error) return c.json({ success: false, error }, 500);
86
- return c.json({ success: true, users, count: users.length });
87
- });
88
-
89
- /** GET /api/providers — list OAuth providers and their status */
90
- app.get("/api/providers", (c) =>
91
- c.json({ providers: listProviderStatus() }),
92
- );
103
+ /** GET /api/supabase/statusconnection status (no secrets) */
104
+ async function getSupabaseStatus() {
105
+ const { client } = getSupabaseClient();
106
+ const url = process.env.SUPABASE_URL || "";
93
107
 
94
- /** GET /api/auth/:provider/authorize — redirect to provider */
95
- app.get("/api/auth/:provider/authorize", async (c) => {
96
- const { provider } = c.req.param();
97
- const query = c.req.query();
98
- const redirectUri = query.redirectUri || `${c.req.url.split("/api/")[0]}/api/auth/${provider}/callback`;
108
+ if (!url) {
109
+ return { connected: false, project: null, scannedFrom: null };
110
+ }
99
111
 
100
- try {
101
- const result = buildAuthorizeUrl({ provider, redirectUri, state: query.state, scope: query.scope });
102
- return c.redirect(result.url);
103
- } catch (e) {
104
- // Provider not found — redirect to authorize page
105
- return c.redirect("/authorize");
112
+ if (!client) {
113
+ return { connected: false, project: null, scannedFrom: process.env.SUPABASE_URL ? "env" : null };
106
114
  }
115
+
116
+ // Try a simple query to verify connection
117
+ const { error } = await client
118
+ .from("authly_users")
119
+ .select("count", { count: "exact", head: true });
120
+
121
+ if (error) return { connected: false, project: url, scannedFrom: "env" };
122
+
123
+ // Check if tokens exist in authly_supabase_tokens
124
+ const { data: tokens } = await client
125
+ .from("authly_supabase_tokens")
126
+ .select("project_name, project_ref")
127
+ .eq("user_id", "00000000-0000-0000-0000-000000000000")
128
+ .maybeSingle();
129
+
130
+ return {
131
+ connected: true,
132
+ project: tokens?.project_name || url,
133
+ scannedFrom: process.env.SUPABASE_OAUTH_CLIENT_ID ? "oauth" : "env",
134
+ };
135
+ }
136
+
137
+ app.get("/api/supabase/status", async (c) => {
138
+ const status = await getSupabaseStatus();
139
+ return c.json(status);
107
140
  });
108
141
 
109
- /** GET /authorizesign-in page listing available providers */
110
- app.get("/authorize", (c) => {
111
- if (hasDashboard) {
112
- const html = fs.readFileSync(path.join(dashboardPath, "authorize.html"), "utf-8");
113
- return c.html(html);
114
- }
115
- return c.json({ providers: listProviderStatus() });
142
+ /** POST /api/supabase/scanscan CWD and attempt Supabase connection */
143
+ app.post("/api/supabase/scan", async (c) => {
144
+ const scan = scanSupabase(process.cwd());
145
+ return c.json({
146
+ found: scan.canConnect,
147
+ fields: scan.canConnect
148
+ ? ["SUPABASE_URL", "SUPABASE_ANON_KEY", "SUPABASE_SERVICE_ROLE_KEY"]
149
+ : ["SUPABASE_URL", "SUPABASE_ANON_KEY", "SUPABASE_SERVICE_ROLE_KEY"].filter((f) => {
150
+ const key = f === "SUPABASE_SERVICE_ROLE_KEY" ? "serviceKey" : f === "SUPABASE_ANON_KEY" ? "anonKey" : "url";
151
+ return !scan[key];
152
+ }),
153
+ });
116
154
  });
117
155
 
118
- // ── Supabase Platform OAuth with PKCE ───────────────
156
+ /** GET /api/auth/supabase always return OAuth authorize URL (never { status: "connected" }) */
157
+ app.get("/api/auth/supabase", (c) => {
158
+ try {
159
+ const { verifier, challenge } = generatePKCE();
160
+ const state = randomBytes(16).toString("hex");
161
+ pkceState.set(state, { verifier, challenge });
162
+ setTimeout(() => pkceState.delete(state), 10 * 60 * 1000);
163
+
164
+ const result = buildSupabaseAuthorizeUrl({ state, codeChallenge: challenge });
165
+ return c.json({ url: result.url });
166
+ } catch (e) {
167
+ return c.json({ error: e.message }, 400);
168
+ }
169
+ });
119
170
 
120
- /** GET /api/auth/supabase/authorize — start Supabase OAuth flow */
171
+ /** GET /api/auth/supabase/authorize — start OAuth flow via redirect */
121
172
  app.get("/api/auth/supabase/authorize", async (c) => {
122
- const query = c.req.query();
123
173
  const { verifier, challenge } = generatePKCE();
124
174
  const state = randomBytes(16).toString("hex");
125
-
126
- // Store verifier for later exchange
127
175
  pkceState.set(state, { verifier, challenge });
128
-
129
- // Auto-expire state after 10 min
130
176
  setTimeout(() => pkceState.delete(state), 10 * 60 * 1000);
131
177
 
132
- const result = buildSupabaseAuthorizeUrl({
133
- state,
134
- codeChallenge: challenge,
135
- organizationSlug: query.organization,
136
- });
137
-
138
- return c.redirect(result.url);
178
+ try {
179
+ const result = buildSupabaseAuthorizeUrl({ state, codeChallenge: challenge });
180
+ return c.redirect(result.url);
181
+ } catch (e) {
182
+ return c.json({ error: e.message }, 400);
183
+ }
139
184
  });
140
185
 
141
186
  /** GET /api/auth/supabase/callback — OAuth callback with PKCE */
142
187
  app.get("/api/auth/supabase/callback", async (c) => {
143
- const query = c.req.query();
144
- const code = query.code;
145
- const state = query.state;
146
-
147
- // Validate state and get stored verifier
188
+ const { code, state } = c.req.query();
148
189
  const stored = pkceState.get(state);
149
- if (!stored) {
150
- return c.html(`<h1>Invalid or expired state token</h1>
151
- <p>Return to <a href="/">dashboard</a></p>`, 400);
152
- }
153
-
190
+ if (!stored) return c.text("Invalid or expired state", 400);
154
191
  pkceState.delete(state);
155
-
156
- if (!code) {
157
- return c.html(`<h1>Authorization failed</h1>
158
- <p>Return to <a href="/">dashboard</a></p>`, 400);
159
- }
192
+ if (!code) return c.text("Authorization failed: no code", 400);
160
193
 
161
194
  try {
162
- // Exchange code for tokens
163
- const tokens = await exchangeSupabaseToken({
164
- code,
165
- verifier: stored.verifier,
166
- });
167
-
168
- // Save tokens to DB
169
- await saveSupabaseTokens({
170
- accessToken: tokens.access_token,
171
- refreshToken: tokens.refresh_token,
172
- expiresIn: tokens.expires_in,
173
- });
174
-
175
- // Auto-configure: get project, extract keys, fill .env.local
176
- const config = await autoConfigureFromToken(tokens.access_token);
177
-
178
- if (config) {
179
- return c.html(`<div style="font-family:sans-serif;max-width:500px;margin:60px auto;text-align:center">
180
- <h1 style="color:#22c55e">Connected to Supabase</h1>
181
- <p>Project: <strong>${config.projectName}</strong></p>
182
- <p><code style="background:#eee;padding:2px 8px;border-radius:4px">${config.projectRef}</code></p>
183
- <p style="color:#666">.env.local and authly.config.json have been updated.</p>
184
- <a href="/" style="display:inline-block;margin-top:16px;padding:8px 24px;background:#111;color:#fff;text-decoration:none;border-radius:6px">Go to Dashboard</a>
185
- </div>`);
195
+ const tokens = await exchangeSupabaseToken({ code, verifier: stored.verifier });
196
+ await saveSupabaseTokens({ accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresIn: tokens.expires_in });
197
+
198
+ // Get project info
199
+ const allProjects = await getProjects(tokens.access_token);
200
+ const project = allProjects?.[0];
201
+
202
+ if (project) {
203
+ const keys = await getProjectApiKeys(tokens.access_token, project.id);
204
+ const anon = keys.find((k) => k.type === "anon")?.api_key || "";
205
+ const svc = keys.find((k) => k.type === "service_role")?.api_key || "";
206
+
207
+ const { client } = getSupabaseClient();
208
+ if (client) {
209
+ await client
210
+ .from("authly_supabase_tokens")
211
+ .update({ project_ref: project.id, project_name: project.name })
212
+ .eq("user_id", "00000000-0000-0000-0000-000000000000");
213
+ }
214
+
215
+ // Show project info for manual copy-paste
216
+ return c.html(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Authly Connected</title>
217
+ <style>body{font-family:sans-serif;background:#0a0a0a;color:#fff;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}.card{background:#111;border:1px solid #222;border-radius:12px;padding:40px 32px;max-width:540px;width:100%;text-align:center}h1{color:#22c55e;margin:0 0 12px}.code{background:#0a0a0a;border:1px solid #333;border-radius:8px;padding:16px;text-align:left;font-size:.8rem;font-family:monospace;overflow-x:auto;color:#ccc;margin:12px 0;line-height:1.8;word-break:break-all}a{display:inline-block;margin-top:16px;padding:10px 24px;background:#444;color:#fff;text-decoration:none;border-radius:6px}</style></head><body><div class="card"><h1>Connected to Supabase</h1><p>Project: <strong>${project.name}</strong></p><p><code>${project.id}</code></p><div class="code"><div><span style="color:#666">SUPABASE_URL=</span>"<span style="color:#22c55e">https://${project.id}.supabase.co</span>"</div><div><span style="color:#666">SUPABASE_ANON_KEY=</span>"<span style="color:#22c55e">${anon}</span>"</div><div><span style="color:#666">SUPABASE_SERVICE_ROLE_KEY=</span>"<span style="color:#22c55e">${svc}</span>"</div></div><a href="/">Back to Dashboard</a></div></body></html>`);
186
218
  }
187
219
 
188
- return c.html(`<div style="font-family:sans-serif;max-width:500px;margin:60px auto;text-align:center">
189
- <h1 style="color:#22c55e">Authenticated</h1>
190
- <p>Supabase tokens saved successfully.</p>
191
- <p>Return to <a href="/">dashboard</a>.</p>
192
- </div>`);
220
+ return c.html(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Authly</title><style>body{font-family:sans-serif;background:#0a0a0a;color:#fff;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}.card{background:#111;border:1px solid #222;border-radius:12px;padding:40px 32px;max-width:540px;width:100%;text-align:center}h1{color:#22c55e;margin:0 0 12px}a{color:#58a6ff}</style></head><body><div class="card"><h1>Authenticated</h1><p>Tokens saved.</p><a href="/">Back to Dashboard</a></div></body></html>`);
193
221
  } catch (e) {
194
- return c.html(`<h1>Token exchange failed</h1><p>${e.message}</p>
195
- <p>Return to <a href="/">dashboard</a></p>`, 400);
222
+ return c.html(`<h1>Token exchange failed</h1><p>${e.message}</p><p><a href="/">dashboard</a></p>`, 400);
196
223
  }
197
224
  });
198
225
 
199
- /** GET /api/supabase/projects — list Supabase projects */
226
+ /** GET /api/supabase/projects — list Supabase projects via Platform API */
200
227
  app.get("/api/supabase/projects", async (c) => {
201
228
  try {
202
229
  const token = await ensureValidAccessToken();
203
- if (!token) return c.json({ error: "Not connected to Supabase — visit /api/auth/supabase/authorize" }, 401);
230
+ if (!token) return c.json({ error: "Not connected to Supabase" }, 401);
204
231
  const projects = await getProjects(token);
205
232
  return c.json({ success: true, projects });
206
233
  } catch (e) {
@@ -215,176 +242,296 @@ export async function cmdServe() {
215
242
  const token = await ensureValidAccessToken();
216
243
  if (!token) return c.json({ error: "Not connected to Supabase" }, 401);
217
244
  const keys = await getProjectApiKeys(token, ref);
218
- return c.json({ success: true, keys });
245
+ return c.json({ success: true, keys: keys.filter((k) => ["anon", "service_role"].includes(k.type)) });
219
246
  } catch (e) {
220
247
  return c.json({ error: e.message }, 500);
221
248
  }
222
249
  });
223
250
 
224
- /** POST /api/auth/supabase/refresh — refresh expired token */
251
+ /** POST /api/auth/supabase/refresh — refresh expired Supabase token */
225
252
  app.post("/api/auth/supabase/refresh", async (c) => {
226
253
  try {
227
254
  const token = await ensureValidAccessToken();
228
- if (!token) return c.json({ error: "Not connected to Supabase" }, 401);
229
- return c.json({ success: true });
255
+ return token ? c.json({ success: true }) : c.json({ error: "Not connected to Supabase" }, 401);
230
256
  } catch (e) {
231
257
  return c.json({ error: e.message }, 500);
232
258
  }
233
259
  });
234
260
 
235
- /** POST /api/auth/:provider/authorize — get OAuth URL as JSON */
236
- app.post("/api/auth/:provider/authorize", async (c) => {
237
- const { provider } = c.req.param();
261
+ // ── Config ──────────────────────────────────────────
262
+
263
+ /** GET /api/config return authly.config.json without secrets */
264
+ app.get("/api/config", (c) => {
265
+ // Local server config read
266
+ const config = readConfig();
267
+ if (!config) return c.json({});
268
+
269
+ // Sanitize: remove secrets
270
+ if (config.supabase) {
271
+ if (config.supabase.anonKey) config.supabase.anonKey = "set";
272
+ if (config.supabase.serviceRoleKey) config.supabase.serviceRoleKey = "set";
273
+ }
274
+ return c.json(config);
275
+ });
276
+
277
+ /** POST /api/config — update authly.config.json fields */
278
+ app.post("/api/config", async (c) => {
238
279
  const body = await c.req.json();
239
- const result = buildAuthorizeUrl({
240
- provider,
241
- redirectUri: body.redirectUri,
242
- state: body.state,
243
- scope: body.scope,
244
- });
245
- if (result.error) return c.json({ error: result.error }, 400);
280
+ const config = readConfig() || { framework: "unknown" };
281
+
282
+ if (body.type === "supabase" && body.fields?.length) {
283
+ // Apply scan results to config
284
+ const scan = scanSupabase(process.cwd());
285
+ if (scan.canConnect) {
286
+ config.supabase = {
287
+ url: scan.url,
288
+ anonKey: scan.anonKey,
289
+ serviceRoleKey: scan.serviceKey,
290
+ projectRef: scan.projectRef || "",
291
+ };
292
+ process.env.SUPABASE_URL = scan.url;
293
+ process.env.SUPABASE_ANON_KEY = scan.anonKey;
294
+ process.env.SUPABASE_SERVICE_ROLE_KEY = scan.serviceKey;
295
+ } else {
296
+ return c.json({ ok: false, error: "Credentials not found in project files" }, 400);
297
+ }
298
+ } else {
299
+ // Direct field updates
300
+ Object.assign(config, body);
301
+ }
302
+
303
+ const configPath = path.join(process.cwd(), "authly.config.json");
304
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
305
+ return c.json({ ok: true });
306
+ });
307
+
308
+ // ── Providers ───────────────────────────────────────
309
+
310
+ /** GET /api/providers — list all providers with status */
311
+ app.get("/api/providers", (c) => {
312
+ // Return structured format: { google: { enabled, hasKeys }, ... }
313
+ const providers = listProviderStatus();
314
+ const result = {};
315
+ for (const p of providers) {
316
+ result[p.name] = { enabled: p.enabled, hasKeys: !!p.enabled, scopes: p.scopes || "" };
317
+ }
246
318
  return c.json(result);
247
319
  });
248
320
 
249
- /** POST /api/auth/ :provider/callback exchange code for session */
250
- app.post("/api/auth/:provider/callback", async (c) => {
251
- const { provider } = c.req.param();
252
- const body = await c.req.json();
253
- const { session, error } = await exchangeOAuthCode({
254
- provider,
255
- code: body.code,
256
- redirectUri: body.redirectUri,
257
- });
258
- if (error) return c.json({ success: false, error }, 400);
321
+ /** POST /api/providers/:name/keysvalidate and save provider keys */
322
+ app.post("/api/providers/:name/keys", async (c) => {
323
+ const { name } = c.req.param();
324
+ const { clientId, clientSecret } = await c.req.json();
259
325
 
260
- // Create an internal Authly session token too
261
- const token = await createSessionToken({
262
- sub: session.user?.id ?? "unknown",
263
- role: session.user?.user_metadata?.role ?? "user",
264
- });
265
- return c.json({ success: true, session, token });
326
+ if (!clientId || !clientSecret) return c.json({ valid: false, error: "Missing clientId or clientSecret" }, 400);
327
+
328
+ // Validate by making a test request
329
+ const testResult = await _testProvider(name);
330
+ if (!testResult.valid) return c.json(testResult, 400);
331
+
332
+ // Save to config
333
+ const config = readConfig() || { framework: "unknown" };
334
+ if (!config.providers) config.providers = {};
335
+ config.providers[name] = { clientId: "set", clientSecret: "set", enabled: true };
336
+
337
+ // Also update env for current process
338
+ process.env[`${name.toUpperCase()}_CLIENT_ID`] = clientId;
339
+ process.env[`${name.toUpperCase()}_CLIENT_SECRET`] = clientSecret;
340
+
341
+ const configPath = path.join(process.cwd(), "authly.config.json");
342
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
343
+
344
+ return c.json({ valid: true });
266
345
  });
267
346
 
268
- /** POST /api/auth/sessioncreate a session token from credentials */
269
- app.post("/api/auth/session", async (c) => {
270
- const body = await c.req.json();
271
- if (!body.sub) return c.json({ error: "Missing 'sub' field" }, 400);
272
- const token = await createSessionToken({
273
- sub: body.sub,
274
- role: body.role ?? "user",
275
- });
276
- return c.json({ success: true, token });
347
+ /** GET /api/providers/:name/guidereturn setup guide for provider */
348
+ app.get("/api/providers/:name/guide", (c) => {
349
+ const { name } = c.req.param();
350
+ const guides = {
351
+ google: {
352
+ steps: [
353
+ "Go to Google Cloud Console → APIs & Services → Credentials",
354
+ "Create a project or select an existing one",
355
+ 'Click "Create Credentials" → OAuth 2.0 Client ID',
356
+ "Application type: Web application",
357
+ "Add the callback URL below as an Authorized redirect URI",
358
+ ],
359
+ callbackUrl: "https://authly.rblez.com/api/auth/google/callback",
360
+ docsUrl: "https://console.cloud.google.com/apis/credentials",
361
+ },
362
+ github: {
363
+ steps: [
364
+ "Go to GitHub Settings → Developer Settings → OAuth Apps",
365
+ 'Click "New OAuth App"',
366
+ "Fill in Application name and Homepage URL",
367
+ "Add the callback URL below as Authorization callback URL",
368
+ ],
369
+ callbackUrl: "https://authly.rblez.com/api/auth/github/callback",
370
+ docsUrl: "https://github.com/settings/applications/new",
371
+ },
372
+ discord: {
373
+ steps: [
374
+ "Go to Discord Developer Portal → Applications",
375
+ "Create a New Application",
376
+ "Go to OAuth2 → Redirects → Add Redirect",
377
+ "Add the callback URL below",
378
+ ],
379
+ callbackUrl: "https://authly.rblez.com/api/auth/discord/callback",
380
+ docsUrl: "https://discord.com/developers/applications",
381
+ },
382
+ };
383
+ const guide = guides[name];
384
+ if (!guide) return c.json({ error: "Unknown provider" }, 400);
385
+ return c.json(guide);
277
386
  });
278
387
 
279
- /** GET /api/auth/meverify session token from Authorization header */
280
- app.get("/api/auth/me", authMiddleware(), (c) =>
281
- c.json({ success: true, session: c.get("session") }),
282
- );
388
+ /** GET /api/providers/:name/testtest provider connection */
389
+ app.get("/api/providers/:name/test", async (c) => {
390
+ const { name } = c.req.param();
391
+ return c.json(await _testProvider(name));
392
+ });
393
+
394
+ // ── OAuth routes (password + generic) ─────────────
283
395
 
284
- // ── Password Auth ──────────────────────────────────
285
- /** POST /api/auth/register — sign up with email + password */
396
+ /** POST /api/auth/register */
286
397
  app.post("/api/auth/register", async (c) => {
287
398
  const body = await c.req.json();
288
- const { user, token, error } = await signUp({
289
- email: body.email,
290
- password: body.password,
291
- name: body.name ?? "",
292
- });
293
- if (error) return c.json({ success: false, error }, 400);
294
- return c.json({ success: true, user, token });
399
+ // Use supabase auth.users table for now
400
+ const { client, errors } = getSupabaseClient();
401
+ if (!client) return c.json({ success: false, error: errors.join(", ") }, 503);
402
+
403
+ if (!body.email || !body.password || body.password.length < 8) {
404
+ return c.json({ success: false, error: "Email and password (min 8 chars) required" }, 400);
405
+ }
406
+
407
+ // Insert into authly_users (we manage our own auth)
408
+ const bcrypt = await import("bcryptjs");
409
+ const passwordHash = await bcrypt.default.hash(body.password, 12);
410
+
411
+ const { data, error } = await client
412
+ .from("authly_users")
413
+ .insert({ email: body.email.toLowerCase(), password_hash: passwordHash })
414
+ .select("id, email, created_at")
415
+ .single();
416
+
417
+ if (error) return c.json({ success: false, error: error.message }, 400);
418
+
419
+ const token = await createSessionToken({ sub: data.id, role: "user" });
420
+ return c.json({ success: true, user: data, token });
295
421
  });
296
422
 
297
- /** POST /api/auth/login — sign in with email + password */
423
+ /** POST /api/auth/login */
298
424
  app.post("/api/auth/login", async (c) => {
299
425
  const body = await c.req.json();
300
- const { user, token, error } = await signIn({
301
- email: body.email,
302
- password: body.password,
303
- });
304
- if (error) return c.json({ success: false, error }, 401);
305
- return c.json({ success: true, user, token });
306
- });
426
+ const { client, errors } = getSupabaseClient();
427
+ if (!client) return c.json({ success: false, error: errors.join(", ") }, 503);
307
428
 
308
- /** GET /api/auth/providers list available and enabled providers */
309
- app.get("/api/auth/providers", (c) => {
310
- const providers = getProviders();
311
- return c.json({ providers });
312
- });
429
+ const { data: user } = await client
430
+ .from("authly_users")
431
+ .select("id, email, password_hash")
432
+ .eq("email", body.email.toLowerCase())
433
+ .single();
313
434
 
314
- /** POST /api/auth/magic-link/send send magic link email */
315
- app.post("/api/auth/magic-link/send", async (c) => {
316
- const body = await c.req.json();
317
- const result = await sendMagicLink({ email: body.email, callbackUrl: body.callbackUrl || "/" });
318
- if (result.error) return c.json({ success: false, error: result.error }, 400);
319
- return c.json({ success: true, sent: true });
435
+ if (!user?.password_hash) return c.json({ success: false, error: "Invalid credentials" }, 401);
436
+
437
+ const bcrypt = await import("bcryptjs");
438
+ const valid = await bcrypt.default.compare(body.password, user.password_hash);
439
+ if (!valid) return c.json({ success: false, error: "Invalid credentials" }, 401);
440
+
441
+ const token = await createSessionToken({ sub: user.id, role: "user" });
442
+ return c.json({ success: true, user: { id: user.id, email: user.email }, token });
320
443
  });
321
444
 
322
- /** POST /api/auth/magic-link/verify — verify magic link token */
323
- app.post("/api/auth/magic-link/verify", async (c) => {
324
- const body = await c.req.json();
325
- const { user, token, error } = await verifyMagicLink({ token: body.token });
326
- if (error) return c.json({ success: false, error }, 401);
327
- return c.json({ success: true, user, token });
445
+ /** POST /api/auth/logout */
446
+ app.post("/api/auth/logout", (c) => c.json({ success: true }));
447
+
448
+ /** GET /api/auth/session verify current session */
449
+ app.get("/api/auth/session", authMiddleware(), (c) => c.json({ success: true, session: c.get("session") }));
450
+
451
+ /** Generic OAuth provider routes */
452
+ app.get("/api/auth/:provider/authorize", async (c) => {
453
+ const { provider } = c.req.param();
454
+ const redirectUri = `${c.req.url.split("/api/")[0]}/api/auth/${provider}/callback`;
455
+ try {
456
+ const result = buildAuthorizeUrl({ provider, redirectUri });
457
+ return c.redirect(result.url);
458
+ } catch {
459
+ return c.json({ error: "Provider not configured" }, 400);
460
+ }
328
461
  });
329
462
 
330
- /** GET /api/config non-sensitive project config */
331
- app.get("/api/config", async (c) => {
332
- const fw = detectFramework();
333
- const { roles = [], error } = await listRoles();
334
- return c.json({
335
- framework: fw ?? null,
336
- providers: listProviderStatus(),
337
- roles,
338
- });
463
+ app.post("/api/auth/:provider/callback", async (c) => {
464
+ const { provider, code } = c.req.param();
465
+ const redirectUri = `${c.req.url.split("/api/")[0]}/api/auth/${provider}/callback`;
466
+ try {
467
+ const user = await exchangeTokens({ provider, code, redirectUri });
468
+ const token = await createSessionToken({ sub: user.id, role: "user" });
469
+ return c.json({ success: true, user, token });
470
+ } catch (e) {
471
+ return c.json({ success: false, error: e.message }, 400);
472
+ }
339
473
  });
340
474
 
341
- // ── Role management (protected) ────────────────────
342
- /** GET /api/roles — list all roles */
343
- app.get("/api/roles", async (c) => {
344
- const { roles, error } = await listRoles();
475
+ // ── Users ───────────────────────────────────────────
476
+
477
+ app.get("/api/users", async (c) => {
478
+ const { client, errors } = getSupabaseClient();
479
+ if (!client) return c.json({ success: false, errors }, 503);
480
+ const { users, error } = await fetchUsers({ limit: 50 });
345
481
  if (error) return c.json({ success: false, error }, 500);
346
- return c.json({ success: true, roles });
482
+ return c.json({ success: true, users, count: users.length });
347
483
  });
348
484
 
349
- /** POST /api/roles create a new role */
350
- app.post("/api/roles", async (c) => {
351
- const { name, description } = await c.req.json();
352
- if (!name) return c.json({ error: "'name' is required" }, 400);
353
- const result = await createRole(name, description ?? "");
354
- if (!result.success) return c.json(result, 400);
355
- return c.json(result);
485
+ app.get("/api/users/:id/roles", async (c) => {
486
+ const { id } = c.req.param();
487
+ const { roles, error } = await getUserRoles(id);
488
+ if (error) return c.json({ success: false, error }, 500);
489
+ return c.json({ success: true, userId: id, roles });
356
490
  });
357
491
 
358
- /** POST /api/roles/:roleId/users/:userId/assign assign role to user */
359
- app.post("/api/roles/:roleName/users/:userId/assign", async (c) => {
360
- const { roleName, userId } = c.req.param();
361
- const result = await assignRoleToUser(userId, roleName);
492
+ app.post("/api/users/:id/roles", async (c) => {
493
+ const { id } = c.req.param();
494
+ const { role } = await c.req.json();
495
+ if (!role) return c.json({ error: "'role' is required" }, 400);
496
+ const result = await assignRoleToUser(id, role);
362
497
  if (!result.success) return c.json(result, 400);
363
498
  return c.json(result);
364
499
  });
365
500
 
366
- /** DELETE /api/roles/:roleId/users/:userId/revoke revoke role from user */
367
- app.delete("/api/roles/:roleName/users/:userId/revoke", async (c) => {
368
- const { roleName, userId } = c.req.param();
369
- const result = await revokeRoleFromUser(userId, roleName);
501
+ app.delete("/api/users/:id/roles/:role", async (c) => {
502
+ const { id, role } = c.req.param();
503
+ const result = await revokeRoleFromUser(id, role);
370
504
  if (!result.success) return c.json(result, 400);
371
505
  return c.json(result);
372
506
  });
373
507
 
374
- /** GET /api/users/:userId/roles — get roles for a user */
375
- app.get("/api/users/:userId/roles", async (c) => {
376
- const { userId } = c.req.param();
377
- const { roles, error } = await getUserRoles(userId);
508
+ // ── Roles ───────────────────────────────────────────
509
+
510
+ app.get("/api/roles", async (c) => {
511
+ const { roles, error } = await listRoles();
378
512
  if (error) return c.json({ success: false, error }, 500);
379
- return c.json({ success: true, userId, roles });
513
+ return c.json({ success: true, roles });
380
514
  });
381
515
 
382
- // ── API Keys ───────────────────────────────────────
383
- /** POST /api/keys generate a new API key */
384
- app.post("/api/keys", async (c) => {
516
+ app.post("/api/roles", async (c) => {
517
+ const { name, description } = await c.req.json();
518
+ if (!name) return c.json({ error: "'name' is required" }, 400);
519
+ return c.json(await createRole(name, description || ""));
520
+ });
521
+
522
+ // ── API Keys ────────────────────────────────────────
523
+
524
+ app.get("/api/keys", async (c) => {
385
525
  const { client, errors } = getSupabaseClient();
386
526
  if (!client) return c.json({ success: false, errors }, 503);
527
+ const { data, error } = await client.from("api_keys").select("id, name, key_hash, scopes, expires_at").order("created_at", { ascending: false });
528
+ if (error) return c.json({ success: false, error: error.message }, 500);
529
+ return c.json({ success: true, keys: data || [] });
530
+ });
387
531
 
532
+ app.post("/api/keys", async (c) => {
533
+ const { client } = getSupabaseClient();
534
+ if (!client) return c.json({ success: false, error: "Not connected" }, 503);
388
535
  const body = await c.req.json();
389
536
  if (!body.name) return c.json({ error: "'name' is required" }, 400);
390
537
 
@@ -392,188 +539,161 @@ export async function cmdServe() {
392
539
  const keyHash = createHash("sha256").update(rawKey).digest("hex");
393
540
 
394
541
  const { error } = await client.from("api_keys").insert({
395
- key_hash: keyHash,
396
- name: body.name,
397
- scopes: body.scopes ?? ["read"],
398
- user_id: body.userId ?? null,
399
- expires_at: body.expiresAt ?? null,
542
+ key_hash: keyHash, name: body.name, scopes: body.scopes ?? ["read"],
543
+ user_id: body.userId ?? null, expires_at: body.expiresAt ?? null,
400
544
  });
401
545
  if (error) return c.json({ success: false, error: error.message }, 400);
402
-
403
- // Return raw key ONCE — it cannot be retrieved later
404
546
  return c.json({ success: true, key: rawKey, hashesTo: keyHash });
405
547
  });
406
548
 
407
- // ── Migrations ─────────────────────────────────────
408
- /** GET /api/migrations list available migrations */
409
- app.get("/api/migrations", (c) =>
410
- c.json({ success: true, migrations: listMigrations() }),
411
- );
412
-
413
- /** GET /api/migrations/:name/sql get SQL for a migration */
414
- app.get("/api/migrations/:name/sql", (c) => {
415
- const { name } = c.req.param();
416
- const sql = getMigration(name);
417
- if (!sql) return c.json({ error: `Migration '${name}' not found` }, 404);
418
- return c.json({ success: true, name, sql });
549
+ app.delete("/api/keys/:id", async (c) => {
550
+ const { client } = getSupabaseClient();
551
+ if (!client) return c.json({ success: false, error: "Not connected" }, 503);
552
+ const { id } = c.req.param();
553
+ const { error } = await client.from("api_keys").delete().eq("id", id);
554
+ if (error) return c.json({ success: false, error: error.message }, 500);
555
+ return c.json({ success: true });
419
556
  });
420
557
 
421
- /** POST /api/migrations/:name/run — execute a migration against Supabase */
422
- app.post("/api/migrations/:name/run", async (c) => {
423
- const { client, errors } = getSupabaseClient();
424
- if (!client) return c.json({ success: false, errors }, 503);
425
-
426
- const { name } = c.req.param();
427
- const sql = getMigration(name);
428
- if (!sql) return c.json({ error: `Migration '${name}' not found` }, 404);
558
+ // ── Migrations ──────────────────────────────────────
429
559
 
430
- const { error } = await client.rpc("exec_sql", { sql_query: sql });
431
- if (error) return c.json({ success: false, error: error.message }, 400);
432
- return c.json({ success: true, migration: name });
433
- });
560
+ /** GET /api/migrations list with applied/pending status */
561
+ app.get("/api/migrations", async (c) => {
562
+ const { client } = getSupabaseClient();
563
+ const migrationList = migrations.map((m) => ({ id: m.name, name: m.name, status: "pending" }));
434
564
 
435
- // ── Scaffold ───────────────────────────────────────
436
- /** POST /api/scaffold/preview preview generated code without writing */
437
- app.post("/api/scaffold/preview", async (c) => {
438
- const { type } = await c.req.json();
439
- if (!["login", "signup", "middleware", "route-login", "route-signup"].includes(type)) {
440
- return c.json({ error: `Unknown type '${type}'. Valid: login, signup, middleware, route-login, route-signup` }, 400);
565
+ if (client) {
566
+ // Check which migrations already exist by looking for tables
567
+ try {
568
+ const { data: tables } = await client
569
+ .from("information_schema.tables")
570
+ .select("table_name")
571
+ .eq("table_schema", "public");
572
+ const existingTables = new Set((tables || []).map((t) => t.table_name));
573
+
574
+ for (const m of migrationList) {
575
+ // Extract table name from migration (simplified check)
576
+ if (m.name.includes("roles_table") && existingTables.has("roles")) m.status = "applied";
577
+ if (m.name.includes("users_table") && existingTables.has("authly_users")) m.status = "applied";
578
+ if (m.name.includes("oauth_accounts") && existingTables.has("authly_oauth_accounts")) m.status = "applied";
579
+ if (m.name.includes("user_roles") && existingTables.has("authly_user_roles")) m.status = "applied";
580
+ if (m.name.includes("api_keys") && existingTables.has("authly_api_keys")) m.status = "applied";
581
+ if (m.name.includes("sessions") && existingTables.has("authly_sessions")) m.status = "applied";
582
+ if (m.name.includes("magic_links") && existingTables.has("authly_magic_links")) m.status = "applied";
583
+ if (m.name.includes("supabase_tokens") && existingTables.has("authly_supabase_tokens")) m.status = "applied";
584
+ }
585
+ } catch {
586
+ // Can't check — show all as pending
587
+ }
441
588
  }
442
- return c.json({ success: true, type, code: previewGenerated(type) });
589
+
590
+ return c.json({ success: true, migrations: migrationList });
443
591
  });
444
592
 
445
- /** POST /api/scaffold/generatewrite auth files to a project */
446
- app.post("/api/scaffold/generate", async (c) => {
447
- const { targetDir } = await c.req.json();
448
- if (!targetDir) return c.json({ error: "'targetDir' is required" }, 400);
593
+ /** POST /api/migrations/:id/runrun a single migration */
594
+ app.post("/api/migrations/:id/run", async (c) => {
595
+ const { client, errors } = getSupabaseClient();
596
+ if (!client) return c.json({ success: false, error: errors.join(", ") }, 503);
597
+
598
+ const { id } = c.req.param();
599
+ const sql = getMigration(id);
600
+ if (!sql) return c.json({ ok: false, error: `Migration '${id}' not found` }, 404);
601
+
449
602
  try {
450
- const { files } = await scaffoldAuth(targetDir, { apiRoutes: true });
451
- c.json({ success: true, files });
603
+ const { error } = await client.rpc("exec_sql", { sql_query: sql });
604
+ if (error) return c.json({ ok: false, error: error.message }, 400);
605
+ return c.json({ ok: true, output: `Migration '${id}' applied successfully` });
452
606
  } catch (e) {
453
- c.json({ success: false, error: e.message }, 500);
607
+ return c.json({ ok: false, error: e.message }, 500);
454
608
  }
455
609
  });
456
610
 
457
- // ── Init ──────────────────────────────────────────
458
- /** GET /api/init/scan autodetect Supabase config in current project */
459
- app.get("/api/init/scan", (c) => {
460
- const projectRoot = path.resolve(process.cwd());
461
- const scan = scanSupabase(projectRoot);
462
- return c.json(scan);
611
+ /** POST /api/migrations/pending/run — run all pending migrations */
612
+ app.post("/api/migrations/pending/run", async (c) => {
613
+ const { client, errors } = getSupabaseClient();
614
+ if (!client) return c.json({ ok: false, error: errors.join(", ") }, 503);
615
+
616
+ const outputs = [];
617
+ for (const m of migrations) {
618
+ const sql = getMigration(m.name);
619
+ if (!sql) continue;
620
+ try {
621
+ const { error } = await client.rpc("exec_sql", { sql_query: sql });
622
+ outputs.push(error ? `FAIL: ${m.name} — ${error.message}` : `OK: ${m.name}`);
623
+ } catch (e) {
624
+ outputs.push(`FAIL: ${m.name} — ${e.message}`);
625
+ }
626
+ }
627
+ return c.json({ ok: true, output: outputs.join("\n") });
463
628
  });
464
629
 
465
- /** POST /api/init/connect — connect with autodetected or manual config */
466
- app.post("/api/init/connect", async (c) => {
467
- const body = await c.req.json().catch(() => ({}));
468
-
469
- // Auto-detect first
470
- const projectRoot = path.resolve(process.cwd());
471
- const scan = scanSupabase(projectRoot);
472
-
473
- // Merge scan results with any manually provided values
474
- const url = scan.url || body.supabaseUrl || process.env.SUPABASE_URL || "";
475
- const anonKey = scan.anonKey || body.supabaseAnonKey || process.env.SUPABASE_ANON_KEY || "";
476
- const serviceKey = scan.serviceKey || body.supabaseServiceKey || process.env.SUPABASE_SERVICE_ROLE_KEY || "";
477
-
478
- // Set env vars from detected config
479
- if (url) process.env.SUPABASE_URL = url;
480
- if (anonKey) process.env.SUPABASE_ANON_KEY = anonKey;
481
- if (serviceKey) process.env.SUPABASE_SERVICE_ROLE_KEY = serviceKey;
630
+ // ── Scaffold ────────────────────────────────────────
482
631
 
483
- const fw = scan.framework || detectFramework();
484
- if (!fw && !body.supabaseUrl) return c.json({ success: false, error: "No Next.js project detected" }, 400);
485
-
486
- // Generate .env.local if needed
487
- if (!fs.existsSync(".env.local")) {
488
- await generateEnv(".env.local");
632
+ app.post("/api/scaffold/preview", async (c) => {
633
+ const { type } = await c.req.json();
634
+ if (!["login", "signup", "middleware", "route-login", "route-signup"].includes(type)) {
635
+ return c.json({ error: `Unknown type '${type}'` }, 400);
489
636
  }
490
-
491
- // Write authly.config.json
492
- const config = {
493
- framework: fw || "unknown",
494
- supabase: {
495
- url,
496
- projectRef: scan.projectRef || "",
497
- anonKey: anonKey ? "set" : "",
498
- serviceKey: serviceKey ? "set" : "",
499
- autoDetected: scan.detected,
500
- sources: scan.sources,
501
- },
502
- };
503
- fs.writeFileSync("authly.config.json", JSON.stringify(config, null, 2) + "\n");
504
-
505
- return c.json({
506
- success: true,
507
- framework: fw,
508
- supabase: {
509
- url,
510
- detected: scan.detected,
511
- canConnect: scan.canConnect,
512
- sources: scan.sources,
513
- },
514
- });
637
+ return c.json({ success: true, type, code: previewGenerated(type) });
515
638
  });
516
639
 
517
- // ── MCP (beta) ─────────────────────────────────────
518
- mountMcp(app);
640
+ // ── Audit ───────────────────────────────────────────
519
641
 
520
- // ── Audit ──────────────────────────────────────────
521
- app.post("/api/audit", async (c) => {
642
+ /** GET /api/audit — return issues with error/warn levels */
643
+ app.get("/api/audit", async (c) => {
522
644
  const issues = [];
523
-
524
- // Env vars
525
- for (const key of ["SUPABASE_URL", "SUPABASE_ANON_KEY", "AUTHLY_SECRET"]) {
526
- if (!process.env[key]) issues.push({ check: key, status: "fail", detail: "not set" });
527
- else issues.push({ check: key, status: "ok" });
645
+ const required = [
646
+ { key: "SUPABASE_URL", level: "error" },
647
+ { key: "SUPABASE_ANON_KEY", level: "error" },
648
+ { key: "AUTHLY_SECRET", level: "error" },
649
+ { key: "GOOGLE_CLIENT_ID", level: "warn" },
650
+ { key: "GITHUB_CLIENT_ID", level: "warn" },
651
+ ];
652
+
653
+ for (const { key, level } of required) {
654
+ const value = process.env[key];
655
+ if (value) {
656
+ issues.push({ level: "ok", message: `${key} is set` });
657
+ } else {
658
+ issues.push({ level, message: `${key} is not set` });
659
+ }
528
660
  }
529
661
 
530
- // Supabase connection
531
662
  const { client } = getSupabaseClient();
532
663
  if (client) {
533
- const { error } = await client
534
- .from("auth.users")
535
- .select("count", { count: "exact", head: true });
536
- if (error)
537
- issues.push({ check: "supabase_connection", status: "fail", detail: error.message });
538
- else
539
- issues.push({ check: "supabase_connection", status: "ok" });
664
+ const { error } = await client.from("authly_users").select("count", { count: "exact", head: true });
665
+ if (error) issues.push({ level: "error", message: `Supabase connection failed: ${error.message}` });
666
+ else issues.push({ level: "ok", message: "Supabase connection OK" });
540
667
  } else {
541
- issues.push({ check: "supabase_connection", status: "fail", detail: "not configured" });
668
+ issues.push({ level: "error", message: "Supabase client not available" });
542
669
  }
543
670
 
544
- const allOk = issues.every((i) => i.status === "ok");
545
- return c.json({ success: allOk, issues });
671
+ return c.json({ success: issues.every((i) => i.level !== "error"), issues });
546
672
  });
547
673
 
674
+ // ── MCP ─────────────────────────────────────────────
675
+ mountMcp(app);
676
+
548
677
  // ── Root ───────────────────────────────────────────
678
+
549
679
  app.get("/", (c) => {
550
680
  if (hasDashboard) {
551
681
  const html = fs.readFileSync(path.join(dashboardPath, "index.html"), "utf-8");
552
682
  return c.html(html);
553
683
  }
554
- return c.json({
555
- name: "authly",
556
- version: "0.1.0",
557
- docs: "/api/health",
558
- });
684
+ return c.json({ name: "authly", version: "0.4.1", docs: "/api/health" });
559
685
  });
560
686
 
561
- // ── Start server ───────────────────────────────────
687
+ // ── Start ───────────────────────────────────────────
688
+
562
689
  if (hasDashboard) {
563
- spinner.succeed(
564
- `Authly dashboard running at ${chalk.cyan(`http://localhost:${PORT}`)}`,
565
- );
690
+ spinner.succeed(`Authly dashboard running at ${chalk.cyan(`http://localhost:${PORT}`)}`);
566
691
  } else {
567
- spinner.succeed(
568
- `Authly API running at ${chalk.cyan(`http://localhost:${PORT}`)}${chalk.dim(" (no dashboard UI)")}`,
569
- );
692
+ spinner.succeed(`Authly API running at ${chalk.cyan(`http://localhost:${PORT}`)}${chalk.dim(" (no dashboard UI)")}`);
570
693
  }
571
694
  console.log(chalk.dim(" Press Ctrl+C to stop\n"));
572
695
 
573
- const server = serve({
574
- fetch: app.fetch,
575
- port: Number(PORT),
576
- });
696
+ const server = serve({ fetch: app.fetch, port: Number(PORT) });
577
697
 
578
698
  process.on("SIGINT", () => {
579
699
  spinner.info("Shutting down authly dashboard");
@@ -582,16 +702,19 @@ export async function cmdServe() {
582
702
  });
583
703
  }
584
704
 
705
+ // ── Helpers ────────────────────────────────────────────
706
+
585
707
  function getContentType(filePath) {
586
708
  const ext = path.extname(filePath);
587
709
  const types = {
588
- ".html": "text/html",
589
- ".css": "text/css",
590
- ".js": "text/javascript",
591
- ".json": "application/json",
592
- ".png": "image/png",
593
- ".svg": "image/svg+xml",
594
- ".ico": "image/x-icon",
710
+ ".html": "text/html", ".css": "text/css", ".js": "text/javascript",
711
+ ".json": "application/json", ".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon",
595
712
  };
596
713
  return types[ext] || "application/octet-stream";
597
714
  }
715
+
716
+ function readConfig() {
717
+ const configPath = path.join(process.cwd(), "authly.config.json");
718
+ if (!fs.existsSync(configPath)) return null;
719
+ try { return JSON.parse(fs.readFileSync(configPath, "utf-8")); } catch { return null; }
720
+ }