@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.
- package/dist/dashboard/app.js +915 -397
- package/dist/dashboard/app.js.map +38 -0
- package/dist/dashboard/index.html +12 -49
- package/dist/dashboard/styles.css +63 -0
- package/package.json +3 -1
- package/src/commands/init.js +24 -29
- package/src/commands/serve.js +499 -376
- package/src/dashboard/api.js +22 -0
- package/src/dashboard/components/states.js +16 -0
- package/src/dashboard/components/toast.js +12 -0
- package/src/dashboard/components/wizard.js +52 -0
- package/src/dashboard/index.js +124 -0
- package/src/dashboard/sections/apikeys.js +85 -0
- package/src/dashboard/sections/audit.js +38 -0
- package/src/dashboard/sections/mcp.js +30 -0
- package/src/dashboard/sections/migrations.js +84 -0
- package/src/dashboard/sections/providers.js +186 -0
- package/src/dashboard/sections/roles.js +61 -0
- package/src/dashboard/sections/supabase.js +151 -0
- package/src/dashboard/sections/users.js +96 -0
- package/src/dashboard/state.js +30 -0
package/src/commands/serve.js
CHANGED
|
@@ -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
|
|
11
|
-
import {
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
98
|
+
// ── Public ──────────────────────────────────────────
|
|
71
99
|
app.get("/api/health", (c) => c.json({ status: "ok", uptime: process.uptime() }));
|
|
72
100
|
|
|
73
|
-
|
|
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/
|
|
81
|
-
|
|
82
|
-
const { client
|
|
83
|
-
|
|
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/status — connection status (no secrets) */
|
|
104
|
+
async function getSupabaseStatus() {
|
|
105
|
+
const { client } = getSupabaseClient();
|
|
106
|
+
const url = process.env.SUPABASE_URL || "";
|
|
93
107
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
/**
|
|
110
|
-
app.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
142
|
+
/** POST /api/supabase/scan — scan 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
|
-
|
|
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
|
|
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
|
-
|
|
133
|
-
state,
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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/
|
|
250
|
-
app.post("/api/
|
|
251
|
-
const {
|
|
252
|
-
const
|
|
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/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();
|
|
259
325
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
/**
|
|
269
|
-
app.
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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);
|
|
277
386
|
});
|
|
278
387
|
|
|
279
|
-
/** GET /api/
|
|
280
|
-
app.get("/api/
|
|
281
|
-
|
|
282
|
-
|
|
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));
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// ── OAuth routes (password + generic) ─────────────
|
|
283
395
|
|
|
284
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
423
|
+
/** POST /api/auth/login */
|
|
298
424
|
app.post("/api/auth/login", async (c) => {
|
|
299
425
|
const body = await c.req.json();
|
|
300
|
-
const {
|
|
301
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
const
|
|
317
|
-
const
|
|
318
|
-
if (
|
|
319
|
-
|
|
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/
|
|
323
|
-
app.post("/api/auth/
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
// ──
|
|
342
|
-
|
|
343
|
-
app.get("/api/
|
|
344
|
-
const {
|
|
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,
|
|
482
|
+
return c.json({ success: true, users, count: users.length });
|
|
347
483
|
});
|
|
348
484
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const {
|
|
352
|
-
if (
|
|
353
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
const {
|
|
361
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
const
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const { roles, error } = await
|
|
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,
|
|
513
|
+
return c.json({ success: true, roles });
|
|
380
514
|
});
|
|
381
515
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
589
|
+
|
|
590
|
+
return c.json({ success: true, migrations: migrationList });
|
|
443
591
|
});
|
|
444
592
|
|
|
445
|
-
/** POST /api/
|
|
446
|
-
app.post("/api/
|
|
447
|
-
const {
|
|
448
|
-
if (!
|
|
593
|
+
/** POST /api/migrations/:id/run — run 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 {
|
|
451
|
-
c.json({
|
|
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({
|
|
607
|
+
return c.json({ ok: false, error: e.message }, 500);
|
|
454
608
|
}
|
|
455
609
|
});
|
|
456
610
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
// ──
|
|
518
|
-
mountMcp(app);
|
|
640
|
+
// ── Audit ───────────────────────────────────────────
|
|
519
641
|
|
|
520
|
-
|
|
521
|
-
app.
|
|
642
|
+
/** GET /api/audit — return issues with error/warn levels */
|
|
643
|
+
app.get("/api/audit", async (c) => {
|
|
522
644
|
const issues = [];
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
535
|
-
|
|
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({
|
|
668
|
+
issues.push({ level: "error", message: "Supabase client not available" });
|
|
542
669
|
}
|
|
543
670
|
|
|
544
|
-
|
|
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
|
|
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
|
-
".
|
|
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
|
+
}
|