@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.
- 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/serve.js +490 -263
- 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,
|
|
@@ -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
|
-
|
|
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
|
|
98
|
+
// ── Public ──────────────────────────────────────────
|
|
68
99
|
app.get("/api/health", (c) => c.json({ status: "ok", uptime: process.uptime() }));
|
|
69
100
|
|
|
70
|
-
|
|
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/
|
|
80
|
-
|
|
81
|
-
|
|
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/status — connection status (no secrets) */
|
|
104
|
+
async function getSupabaseStatus() {
|
|
105
|
+
const { client } = getSupabaseClient();
|
|
106
|
+
const url = process.env.SUPABASE_URL || "";
|
|
89
107
|
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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/
|
|
122
|
-
app.post("/api/
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
+
}),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
state,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
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
|
|
178
|
-
const
|
|
179
|
-
const project =
|
|
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
|
-
|
|
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
|
-
// ──
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
305
|
-
c.json({ success: true, session: c.get("session") }),
|
|
306
|
-
);
|
|
394
|
+
// ── OAuth routes (password + generic) ─────────────
|
|
307
395
|
|
|
308
|
-
|
|
396
|
+
/** POST /api/auth/register */
|
|
397
|
+
app.post("/api/auth/register", async (c) => {
|
|
309
398
|
const body = await c.req.json();
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
return c.json({ success:
|
|
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
|
-
|
|
423
|
+
/** POST /api/auth/login */
|
|
424
|
+
app.post("/api/auth/login", async (c) => {
|
|
316
425
|
const body = await c.req.json();
|
|
317
|
-
const {
|
|
318
|
-
if (
|
|
319
|
-
|
|
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
|
-
|
|
323
|
-
app.
|
|
324
|
-
|
|
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,
|
|
482
|
+
return c.json({ success: true, users, count: users.length });
|
|
327
483
|
});
|
|
328
484
|
|
|
329
|
-
app.
|
|
330
|
-
const {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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/
|
|
338
|
-
const {
|
|
339
|
-
const
|
|
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/
|
|
345
|
-
const {
|
|
346
|
-
const result = await revokeRoleFromUser(
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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,
|
|
513
|
+
return c.json({ success: true, roles });
|
|
356
514
|
});
|
|
357
515
|
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
c.json({ success:
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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 {
|
|
398
|
-
const sql = getMigration(
|
|
399
|
-
if (!sql) return c.json({ error: `Migration '${
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
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}'
|
|
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
|
-
// ──
|
|
416
|
-
mountMcp(app);
|
|
640
|
+
// ── Audit ───────────────────────────────────────────
|
|
417
641
|
|
|
418
|
-
|
|
419
|
-
app.
|
|
642
|
+
/** GET /api/audit — return issues with error/warn levels */
|
|
643
|
+
app.get("/api/audit", async (c) => {
|
|
420
644
|
const issues = [];
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
431
|
-
|
|
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({
|
|
668
|
+
issues.push({ level: "error", message: "Supabase client not available" });
|
|
438
669
|
}
|
|
439
670
|
|
|
440
|
-
|
|
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
|
|
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
|
-
".
|
|
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
|
+
}
|