@rblez/authly 0.4.1 → 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,
@@ -20,36 +38,49 @@ import {
20
38
  import { listMigrations, getMigration, migrations } from "../generators/migrations.js";
21
39
  import { scaffoldAuth, previewGenerated } from "../generators/ui.js";
22
40
  import { mountMcp } from "../mcp/server.js";
23
- import { generatePKCE, buildSupabaseAuthorizeUrl, exchangeSupabaseToken, saveSupabaseTokens } from "../lib/supabase-oauth.js";
24
- import { ensureValidAccessToken, getProjects, getProjectApiKeys } from "../lib/supabase-api.js";
25
41
 
26
42
  const PORT = process.env.PORT || process.env.AUTHLY_PORT || 1284;
27
43
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
44
 
29
- /**
30
- * In-memory PKCE state store (server-side, dev-only).
31
- * Key: state token, Value: { verifier, codeChallenge }
32
- * Survives no restart — fine for local dev.
33
- */
45
+ /** In-memory PKCE state for Supabase OAuth (server-side, dev-only). */
34
46
  const pkceState = new Map();
35
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
+
36
69
  export async function cmdServe() {
37
70
  const spinner = ora("Starting authly dashboard…").start();
38
71
 
39
72
  const app = new Hono();
40
73
 
41
74
  // ── CORS — allow localhost to call hosted API ──
42
- app.use("/api/*", (c) => {
75
+ app.use("/api/*", async (c, next) => {
43
76
  c.header("Access-Control-Allow-Origin", "*");
44
77
  c.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
45
78
  c.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
46
- if (c.req.method === "OPTIONS") {
47
- return new Response(null, { status: 204 });
48
- }
49
- return c.next();
79
+ if (c.req.method === "OPTIONS") return new Response(null, { status: 204 });
80
+ return next();
50
81
  });
51
82
 
52
- // ── Static file serving ────────────────────────────
83
+ // ── Static file serving (local only — Railway has no dashboard) ──
53
84
  const dashboardPath = path.join(__dirname, "../../dist/dashboard");
54
85
  const hasDashboard = fs.existsSync(dashboardPath);
55
86
 
@@ -64,126 +95,115 @@ export async function cmdServe() {
64
95
  });
65
96
  }
66
97
 
67
- // ── Public API ─────────────────────────────────────
98
+ // ── Public ──────────────────────────────────────────
68
99
  app.get("/api/health", (c) => c.json({ status: "ok", uptime: process.uptime() }));
69
100
 
70
- /** GET /api/users — list all users from Supabase */
71
- app.get("/api/users", async (c) => {
72
- const { client, errors } = getSupabaseClient();
73
- if (!client) return c.json({ success: false, errors }, 503);
74
- const { users, error } = await fetchUsers(client);
75
- if (error) return c.json({ success: false, error }, 500);
76
- return c.json({ success: true, users, count: users.length });
77
- });
101
+ // ── Supabase ────────────────────────────────────────
78
102
 
79
- /** GET /api/providerslist OAuth providers and their status */
80
- app.get("/api/providers", (c) =>
81
- c.json({ providers: listProviderStatus() }),
82
- );
83
-
84
- /** GET /api/auth/:provider/authorize — redirect to provider */
85
- app.get("/api/auth/:provider/authorize", async (c) => {
86
- const { provider } = c.req.param();
87
- const query = c.req.query();
88
- const redirectUri = query.redirectUri || `${c.req.url.split("/api/")[0]}/api/auth/${provider}/callback`;
103
+ /** GET /api/supabase/statusconnection status (no secrets) */
104
+ async function getSupabaseStatus() {
105
+ const { client } = getSupabaseClient();
106
+ const url = process.env.SUPABASE_URL || "";
89
107
 
90
- try {
91
- const result = buildAuthorizeUrl({ provider, redirectUri, state: query.state, scope: query.scope });
92
- return c.redirect(result.url);
93
- } catch (e) {
94
- return c.redirect("/authorize");
108
+ if (!url) {
109
+ return { connected: false, project: null, scannedFrom: null };
95
110
  }
96
- });
97
111
 
98
- /** GET /authorize — sign-in page listing available providers */
99
- app.get("/authorize", (c) => {
100
- if (hasDashboard) {
101
- const html = fs.readFileSync(path.join(dashboardPath, "authorize.html"), "utf-8");
102
- return c.html(html);
112
+ if (!client) {
113
+ return { connected: false, project: null, scannedFrom: process.env.SUPABASE_URL ? "env" : null };
103
114
  }
104
- return c.json({ providers: listProviderStatus() });
105
- });
106
115
 
107
- /** POST /api/auth/:provider/authorize get OAuth URL as JSON */
108
- app.post("/api/auth/:provider/authorize", async (c) => {
109
- const { provider } = c.req.param();
110
- const body = await c.req.json();
111
- const result = buildAuthorizeUrl({
112
- provider,
113
- redirectUri: body.redirectUri,
114
- state: body.state,
115
- scope: body.scope,
116
- });
117
- if (result.error) return c.json({ error: result.error }, 400);
118
- return c.json(result);
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);
119
140
  });
120
141
 
121
- /** POST /api/auth/:provider/callbackexchange code for session */
122
- app.post("/api/auth/:provider/callback", async (c) => {
123
- const { provider } = c.req.param();
124
- const body = await c.req.json();
125
- const { user, token, error } = await handleOAuthCallback({
126
- provider,
127
- code: body.code,
128
- redirectUri: body.redirectUri,
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
+ }),
129
153
  });
130
- if (error) return c.json({ success: false, error }, 400);
131
- return c.json({ success: true, user, token });
132
154
  });
133
155
 
134
- // ── 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);
135
163
 
136
- /** GET /api/auth/supabase/authorize start Supabase OAuth flow */
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
+ });
170
+
171
+ /** GET /api/auth/supabase/authorize — start OAuth flow via redirect */
137
172
  app.get("/api/auth/supabase/authorize", async (c) => {
138
- const query = c.req.query();
139
173
  const { verifier, challenge } = generatePKCE();
140
174
  const state = randomBytes(16).toString("hex");
141
175
  pkceState.set(state, { verifier, challenge });
142
176
  setTimeout(() => pkceState.delete(state), 10 * 60 * 1000);
143
177
 
144
- const result = buildSupabaseAuthorizeUrl({
145
- state,
146
- codeChallenge: challenge,
147
- organizationSlug: query.organization,
148
- });
149
- 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
+ }
150
184
  });
151
185
 
152
- /** GET /api/auth/supabase/callback — OAuth callback with PKCE
153
- * Saves tokens to DB. Shows project + API keys directly to user
154
- * for manual copy-paste into .env.local. */
186
+ /** GET /api/auth/supabase/callback — OAuth callback with PKCE */
155
187
  app.get("/api/auth/supabase/callback", async (c) => {
156
- const query = c.req.query();
157
- const code = query.code;
158
- const state = query.state;
159
-
188
+ const { code, state } = c.req.query();
160
189
  const stored = pkceState.get(state);
161
- if (!stored) {
162
- return c.html(`<h1>Invalid or expired state token</h1><p><a href="/">dashboard</a></p>`, 400);
163
- }
190
+ if (!stored) return c.text("Invalid or expired state", 400);
164
191
  pkceState.delete(state);
165
- if (!code) {
166
- return c.html(`<h1>Authorization failed</h1><p><a href="/">dashboard</a></p>`, 400);
167
- }
192
+ if (!code) return c.text("Authorization failed: no code", 400);
168
193
 
169
194
  try {
170
195
  const tokens = await exchangeSupabaseToken({ code, verifier: stored.verifier });
171
- await saveSupabaseTokens({
172
- accessToken: tokens.access_token,
173
- refreshToken: tokens.refresh_token,
174
- expiresIn: tokens.expires_in,
175
- });
196
+ await saveSupabaseTokens({ accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresIn: tokens.expires_in });
176
197
 
177
- // Get project info for success page
178
- const projects = await getProjects(tokens.access_token);
179
- const project = projects?.[0];
198
+ // Get project info
199
+ const allProjects = await getProjects(tokens.access_token);
200
+ const project = allProjects?.[0];
180
201
 
181
202
  if (project) {
182
203
  const keys = await getProjectApiKeys(tokens.access_token, project.id);
183
- const anon = keys.find(k => k.type === "anon")?.api_key || "";
184
- const svc = keys.find(k => k.type === "service_role")?.api_key || "";
204
+ const anon = keys.find((k) => k.type === "anon")?.api_key || "";
205
+ const svc = keys.find((k) => k.type === "service_role")?.api_key || "";
185
206
 
186
- // Save project ref in tokens table
187
207
  const { client } = getSupabaseClient();
188
208
  if (client) {
189
209
  await client
@@ -192,43 +212,18 @@ export async function cmdServe() {
192
212
  .eq("user_id", "00000000-0000-0000-0000-000000000000");
193
213
  }
194
214
 
215
+ // Show project info for manual copy-paste
195
216
  return c.html(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Authly — Connected</title>
196
- <style>
197
- body{font-family:sans-serif;background:#0a0a0a;color:#fff;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
198
- .card{background:#111;border:1px solid #222;border-radius:12px;padding:40px 32px;max-width:540px;width:100%;text-align:center}
199
- h1{color:#22c55e;margin:0 0 12px;font-size:1.5rem}
200
- p{color:#888;font-size:.9rem;margin:0 0 8px}
201
- .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}
202
- .key-val{color:#22c55e}
203
- .key-label{color:#666}
204
- a{display:inline-block;margin-top:16px;padding:10px 24px;background:#444;color:#fff;text-decoration:none;border-radius:6px;font-size:.9rem}
205
- a:hover{background:#555}
206
- </style></head><body><div class="card">
207
- <h1>Connected to Supabase</h1>
208
- <p>Project: <strong>${project.name}</strong></p>
209
- <p><code style="color:#58a6ff">${project.id}</code></p>
210
- <p style="margin-top:20px;text-align:left;color:#aaa;">Add these to your project's <strong>.env.local</strong>:</p>
211
- <div class="code"><span class="key-label">SUPABASE_URL=</span>"<span class="key-val">https://${project.id}.supabase.co</span>"<br>
212
- <span class="key-label">SUPABASE_ANON_KEY=</span>"<span class="key-val">${anon}</span>"<br>
213
- <span class="key-label">SUPABASE_SERVICE_ROLE_KEY=</span>"<span class="key-val">${svc}</span>"<br>
214
- <span class="key-label">AUTHLY_SECRET=</span>"<span class="key-val">generate-with-openssl-rand-hex-32</span>"</div>
215
- <a href="/">Back to Dashboard</a>
216
- </div></body></html>`);
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>`);
217
218
  }
218
219
 
219
- return c.html(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Authly</title>
220
- <style>body{font-family:sans-serif;background:#0a0a0a;color:#fff;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
221
- .card{background:#111;border:1px solid #222;border-radius:12px;padding:40px 32px;max-width:540px;width:100%;text-align:center}
222
- h1{color:#22c55e;margin:0 0 12px}
223
- a{color:#58a6ff}</style></head><body><div class="card">
224
- <h1>Authenticated</h1><p>Supabase tokens saved, no projects found.</p>
225
- <a href="/">Back to Dashboard</a></div></body></html>`);
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>`);
226
221
  } catch (e) {
227
222
  return c.html(`<h1>Token exchange failed</h1><p>${e.message}</p><p><a href="/">dashboard</a></p>`, 400);
228
223
  }
229
224
  });
230
225
 
231
- /** GET /api/supabase/projects — list Supabase projects */
226
+ /** GET /api/supabase/projects — list Supabase projects via Platform API */
232
227
  app.get("/api/supabase/projects", async (c) => {
233
228
  try {
234
229
  const token = await ensureValidAccessToken();
@@ -247,119 +242,296 @@ a{color:#58a6ff}</style></head><body><div class="card">
247
242
  const token = await ensureValidAccessToken();
248
243
  if (!token) return c.json({ error: "Not connected to Supabase" }, 401);
249
244
  const keys = await getProjectApiKeys(token, ref);
250
- return c.json({ success: true, keys: keys.filter(k => ["anon", "service_role"].includes(k.type)) });
245
+ return c.json({ success: true, keys: keys.filter((k) => ["anon", "service_role"].includes(k.type)) });
251
246
  } catch (e) {
252
247
  return c.json({ error: e.message }, 500);
253
248
  }
254
249
  });
255
250
 
256
- /** POST /api/auth/supabase/refresh — refresh expired token */
251
+ /** POST /api/auth/supabase/refresh — refresh expired Supabase token */
257
252
  app.post("/api/auth/supabase/refresh", async (c) => {
258
253
  try {
259
254
  const token = await ensureValidAccessToken();
260
- if (!token) return c.json({ error: "Not connected to Supabase" }, 401);
261
- return c.json({ success: true });
255
+ return token ? c.json({ success: true }) : c.json({ error: "Not connected to Supabase" }, 401);
262
256
  } catch (e) {
263
257
  return c.json({ error: e.message }, 500);
264
258
  }
265
259
  });
266
260
 
267
- // ── Password Auth ──────────────────────────────────
268
- app.post("/api/auth/register", async (c) => {
269
- const body = await c.req.json();
270
- const { user, token, error } = await signUp({
271
- email: body.email,
272
- password: body.password,
273
- name: body.name ?? "",
274
- });
275
- if (error) return c.json({ success: false, error }, 400);
276
- return c.json({ success: true, user, token });
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);
277
275
  });
278
276
 
279
- app.post("/api/auth/login", async (c) => {
277
+ /** POST /api/config update authly.config.json fields */
278
+ app.post("/api/config", async (c) => {
280
279
  const body = await c.req.json();
281
- const { user, token, error } = await signIn({
282
- email: body.email,
283
- password: body.password,
284
- });
285
- if (error) return c.json({ success: false, error }, 401);
286
- return c.json({ success: true, user, token });
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
+ }
318
+ return c.json(result);
287
319
  });
288
320
 
289
- app.get("/api/auth/providers", (c) => {
290
- const providers = getProviders();
291
- return c.json({ providers });
321
+ /** POST /api/providers/:name/keys validate 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();
325
+
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 });
292
345
  });
293
346
 
294
- app.post("/api/auth/session", async (c) => {
295
- const body = await c.req.json();
296
- if (!body.sub) return c.json({ error: "Missing 'sub' field" }, 400);
297
- const token = await createSessionToken({
298
- sub: body.sub,
299
- role: body.role ?? "user",
300
- });
301
- return c.json({ success: true, token });
347
+ /** GET /api/providers/:name/guide return 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);
386
+ });
387
+
388
+ /** GET /api/providers/:name/test — test provider connection */
389
+ app.get("/api/providers/:name/test", async (c) => {
390
+ const { name } = c.req.param();
391
+ return c.json(await _testProvider(name));
302
392
  });
303
393
 
304
- app.get("/api/auth/me", authMiddleware(), (c) =>
305
- c.json({ success: true, session: c.get("session") }),
306
- );
394
+ // ── OAuth routes (password + generic) ─────────────
307
395
 
308
- app.post("/api/auth/magic-link/send", async (c) => {
396
+ /** POST /api/auth/register */
397
+ app.post("/api/auth/register", async (c) => {
309
398
  const body = await c.req.json();
310
- const result = await sendMagicLink({ email: body.email, callbackUrl: body.callbackUrl || "/" });
311
- if (result.error) return c.json({ success: false, error: result.error }, 400);
312
- return c.json({ success: true, sent: true });
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 });
313
421
  });
314
422
 
315
- app.post("/api/auth/magic-link/verify", async (c) => {
423
+ /** POST /api/auth/login */
424
+ app.post("/api/auth/login", async (c) => {
316
425
  const body = await c.req.json();
317
- const { user, token, error } = await verifyMagicLink({ token: body.token });
318
- if (error) return c.json({ success: false, error }, 401);
319
- return c.json({ success: true, user, token });
426
+ const { client, errors } = getSupabaseClient();
427
+ if (!client) return c.json({ success: false, error: errors.join(", ") }, 503);
428
+
429
+ const { data: user } = await client
430
+ .from("authly_users")
431
+ .select("id, email, password_hash")
432
+ .eq("email", body.email.toLowerCase())
433
+ .single();
434
+
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
- // ── Role management ────────────────────────────────
323
- app.get("/api/roles", async (c) => {
324
- const { roles, error } = await listRoles();
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
+ }
461
+ });
462
+
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
+ }
473
+ });
474
+
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 });
325
481
  if (error) return c.json({ success: false, error }, 500);
326
- return c.json({ success: true, roles });
482
+ return c.json({ success: true, users, count: users.length });
327
483
  });
328
484
 
329
- app.post("/api/roles", async (c) => {
330
- const { name, description } = await c.req.json();
331
- if (!name) return c.json({ error: "'name' is required" }, 400);
332
- const result = await createRole(name, description ?? "");
333
- if (!result.success) return c.json(result, 400);
334
- 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 });
335
490
  });
336
491
 
337
- app.post("/api/roles/:roleName/users/:userId/assign", async (c) => {
338
- const { roleName, userId } = c.req.param();
339
- 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);
340
497
  if (!result.success) return c.json(result, 400);
341
498
  return c.json(result);
342
499
  });
343
500
 
344
- app.delete("/api/roles/:roleName/users/:userId/revoke", async (c) => {
345
- const { roleName, userId } = c.req.param();
346
- 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);
347
504
  if (!result.success) return c.json(result, 400);
348
505
  return c.json(result);
349
506
  });
350
507
 
351
- app.get("/api/users/:userId/roles", async (c) => {
352
- const { userId } = c.req.param();
353
- const { roles, error } = await getUserRoles(userId);
508
+ // ── Roles ───────────────────────────────────────────
509
+
510
+ app.get("/api/roles", async (c) => {
511
+ const { roles, error } = await listRoles();
354
512
  if (error) return c.json({ success: false, error }, 500);
355
- return c.json({ success: true, userId, roles });
513
+ return c.json({ success: true, roles });
356
514
  });
357
515
 
358
- // ── API Keys ───────────────────────────────────────
359
- 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) => {
360
525
  const { client, errors } = getSupabaseClient();
361
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
+ });
362
531
 
532
+ app.post("/api/keys", async (c) => {
533
+ const { client } = getSupabaseClient();
534
+ if (!client) return c.json({ success: false, error: "Not connected" }, 503);
363
535
  const body = await c.req.json();
364
536
  if (!body.name) return c.json({ error: "'name' is required" }, 400);
365
537
 
@@ -367,109 +539,161 @@ a{color:#58a6ff}</style></head><body><div class="card">
367
539
  const keyHash = createHash("sha256").update(rawKey).digest("hex");
368
540
 
369
541
  const { error } = await client.from("api_keys").insert({
370
- key_hash: keyHash,
371
- name: body.name,
372
- scopes: body.scopes ?? ["read"],
373
- user_id: body.userId ?? null,
374
- 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,
375
544
  });
376
545
  if (error) return c.json({ success: false, error: error.message }, 400);
377
-
378
546
  return c.json({ success: true, key: rawKey, hashesTo: keyHash });
379
547
  });
380
548
 
381
- // ── Migrations ─────────────────────────────────────
382
- app.get("/api/migrations", (c) =>
383
- c.json({ success: true, migrations: listMigrations() }),
384
- );
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 });
556
+ });
385
557
 
386
- app.get("/api/migrations/:name/sql", (c) => {
387
- const { name } = c.req.param();
388
- const sql = getMigration(name);
389
- if (!sql) return c.json({ error: `Migration '${name}' not found` }, 404);
390
- return c.json({ success: true, name, sql });
558
+ // ── Migrations ──────────────────────────────────────
559
+
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" }));
564
+
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
+ }
588
+ }
589
+
590
+ return c.json({ success: true, migrations: migrationList });
391
591
  });
392
592
 
393
- app.post("/api/migrations/:name/run", async (c) => {
593
+ /** POST /api/migrations/:id/run run a single migration */
594
+ app.post("/api/migrations/:id/run", async (c) => {
394
595
  const { client, errors } = getSupabaseClient();
395
- if (!client) return c.json({ success: false, errors }, 503);
596
+ if (!client) return c.json({ success: false, error: errors.join(", ") }, 503);
396
597
 
397
- const { name } = c.req.param();
398
- const sql = getMigration(name);
399
- if (!sql) return c.json({ error: `Migration '${name}' not found` }, 404);
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);
400
601
 
401
- const { error } = await client.rpc("exec_sql", { sql_query: sql });
402
- if (error) return c.json({ success: false, error: error.message }, 400);
403
- return c.json({ success: true, migration: name });
602
+ try {
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` });
606
+ } catch (e) {
607
+ return c.json({ ok: false, error: e.message }, 500);
608
+ }
404
609
  });
405
610
 
406
- // ── Scaffold preview only ─────────────────────────
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") });
628
+ });
629
+
630
+ // ── Scaffold ────────────────────────────────────────
631
+
407
632
  app.post("/api/scaffold/preview", async (c) => {
408
633
  const { type } = await c.req.json();
409
634
  if (!["login", "signup", "middleware", "route-login", "route-signup"].includes(type)) {
410
- return c.json({ error: `Unknown type '${type}'. Valid: login, signup, middleware, route-login, route-signup` }, 400);
635
+ return c.json({ error: `Unknown type '${type}'` }, 400);
411
636
  }
412
637
  return c.json({ success: true, type, code: previewGenerated(type) });
413
638
  });
414
639
 
415
- // ── MCP (beta) ─────────────────────────────────────
416
- mountMcp(app);
640
+ // ── Audit ───────────────────────────────────────────
417
641
 
418
- // ── Audit ──────────────────────────────────────────
419
- app.post("/api/audit", async (c) => {
642
+ /** GET /api/audit — return issues with error/warn levels */
643
+ app.get("/api/audit", async (c) => {
420
644
  const issues = [];
421
-
422
- for (const key of ["SUPABASE_URL", "SUPABASE_ANON_KEY", "AUTHLY_SECRET"]) {
423
- if (!process.env[key]) issues.push({ check: key, status: "fail", detail: "not set" });
424
- 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
+ }
425
660
  }
426
661
 
427
662
  const { client } = getSupabaseClient();
428
663
  if (client) {
429
- const { error } = await client
430
- .from("auth.users")
431
- .select("count", { count: "exact", head: true });
432
- if (error)
433
- issues.push({ check: "supabase_connection", status: "fail", detail: error.message });
434
- else
435
- 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" });
436
667
  } else {
437
- issues.push({ check: "supabase_connection", status: "fail", detail: "not configured" });
668
+ issues.push({ level: "error", message: "Supabase client not available" });
438
669
  }
439
670
 
440
- const allOk = issues.every((i) => i.status === "ok");
441
- return c.json({ success: allOk, issues });
671
+ return c.json({ success: issues.every((i) => i.level !== "error"), issues });
442
672
  });
443
673
 
674
+ // ── MCP ─────────────────────────────────────────────
675
+ mountMcp(app);
676
+
444
677
  // ── Root ───────────────────────────────────────────
678
+
445
679
  app.get("/", (c) => {
446
680
  if (hasDashboard) {
447
681
  const html = fs.readFileSync(path.join(dashboardPath, "index.html"), "utf-8");
448
682
  return c.html(html);
449
683
  }
450
- return c.json({
451
- name: "authly",
452
- version: "0.4.0",
453
- docs: "/api/health",
454
- });
684
+ return c.json({ name: "authly", version: "0.4.1", docs: "/api/health" });
455
685
  });
456
686
 
457
- // ── Start server ───────────────────────────────────
687
+ // ── Start ───────────────────────────────────────────
688
+
458
689
  if (hasDashboard) {
459
- spinner.succeed(
460
- `Authly dashboard running at ${chalk.cyan(`http://localhost:${PORT}`)}`,
461
- );
690
+ spinner.succeed(`Authly dashboard running at ${chalk.cyan(`http://localhost:${PORT}`)}`);
462
691
  } else {
463
- spinner.succeed(
464
- `Authly API running at ${chalk.cyan(`http://localhost:${PORT}`)}${chalk.dim(" (no dashboard UI)")}`,
465
- );
692
+ spinner.succeed(`Authly API running at ${chalk.cyan(`http://localhost:${PORT}`)}${chalk.dim(" (no dashboard UI)")}`);
466
693
  }
467
694
  console.log(chalk.dim(" Press Ctrl+C to stop\n"));
468
695
 
469
- const server = serve({
470
- fetch: app.fetch,
471
- port: Number(PORT),
472
- });
696
+ const server = serve({ fetch: app.fetch, port: Number(PORT) });
473
697
 
474
698
  process.on("SIGINT", () => {
475
699
  spinner.info("Shutting down authly dashboard");
@@ -478,16 +702,19 @@ a{color:#58a6ff}</style></head><body><div class="card">
478
702
  });
479
703
  }
480
704
 
705
+ // ── Helpers ────────────────────────────────────────────
706
+
481
707
  function getContentType(filePath) {
482
708
  const ext = path.extname(filePath);
483
709
  const types = {
484
- ".html": "text/html",
485
- ".css": "text/css",
486
- ".js": "text/javascript",
487
- ".json": "application/json",
488
- ".png": "image/png",
489
- ".svg": "image/svg+xml",
490
- ".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",
491
712
  };
492
713
  return types[ext] || "application/octet-stream";
493
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
+ }