@rblez/authly 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,383 @@
1
+ import { Hono } from "hono";
2
+ import { serve } from "@hono/node-server";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { createHash, randomBytes } from "node:crypto";
7
+ import chalk from "chalk";
8
+ import ora from "ora";
9
+ import { getSupabaseClient, fetchUsers } from "../lib/supabase.js";
10
+ import { createSessionToken, verifySessionToken, authMiddleware, requireRole } from "../lib/jwt.js";
11
+ import { signUp, signIn, signOut, getSession, getProviders, handleOAuthCallback } from "../auth/index.js";
12
+ import { buildAuthorizeUrl, exchangeTokens, listProviderStatus } from "../lib/oauth.js";
13
+ import {
14
+ createRole,
15
+ assignRoleToUser,
16
+ revokeRoleFromUser,
17
+ getUserRoles,
18
+ listRoles,
19
+ } from "../generators/roles.js";
20
+ import { listMigrations, getMigration, migrations } from "../generators/migrations.js";
21
+ import { detectFramework } from "../lib/framework.js";
22
+ import { scaffoldAuth, previewGenerated } from "../generators/ui.js";
23
+ import { generateEnv } from "../generators/env.js";
24
+ import { mountMcp } from "../mcp/server.js";
25
+
26
+ const PORT = process.env.AUTHLY_PORT || 1284;
27
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
+
29
+ export async function cmdServe() {
30
+ const spinner = ora("Starting authly dashboard…").start();
31
+
32
+ const app = new Hono();
33
+
34
+ // ── Static file serving ────────────────────────────
35
+ const dashboardPath = path.join(__dirname, "../../dist/dashboard");
36
+ const hasDashboard = fs.existsSync(dashboardPath);
37
+
38
+ if (hasDashboard) {
39
+ app.use("/*", async (c, next) => {
40
+ const filePath = path.join(dashboardPath, c.req.path.replace(/^\//, ""));
41
+ if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
42
+ c.header("Content-Type", getContentType(filePath));
43
+ return c.body(fs.readFileSync(filePath));
44
+ }
45
+ return next();
46
+ });
47
+ }
48
+
49
+ // ── Public API ─────────────────────────────────────
50
+ app.get("/api/health", (c) => c.json({ status: "ok", uptime: process.uptime() }));
51
+
52
+ /** GET /api/users — list all users from Supabase */
53
+ app.get("/api/users", async (c) => {
54
+ const { client, errors } = getSupabaseClient();
55
+ if (!client) return c.json({ success: false, errors }, 503);
56
+ const { users, error } = await fetchUsers(client);
57
+ if (error) return c.json({ success: false, error }, 500);
58
+ return c.json({ success: true, users, count: users.length });
59
+ });
60
+
61
+ /** GET /api/providers — list OAuth providers and their status */
62
+ app.get("/api/providers", (c) =>
63
+ c.json({ providers: listProviderStatus() }),
64
+ );
65
+
66
+ /** POST /api/auth/:provider/authorize — get OAuth URL */
67
+ app.post("/api/auth/:provider/authorize", async (c) => {
68
+ const { provider } = c.req.param();
69
+ const body = await c.req.json();
70
+ const result = buildAuthorizeUrl({
71
+ provider,
72
+ redirectUri: body.redirectUri,
73
+ state: body.state,
74
+ scope: body.scope,
75
+ });
76
+ if (result.error) return c.json({ error: result.error }, 400);
77
+ return c.json(result);
78
+ });
79
+
80
+ /** POST /api/auth/ :provider/callback — exchange code for session */
81
+ app.post("/api/auth/:provider/callback", async (c) => {
82
+ const { provider } = c.req.param();
83
+ const body = await c.req.json();
84
+ const { session, error } = await exchangeOAuthCode({
85
+ provider,
86
+ code: body.code,
87
+ redirectUri: body.redirectUri,
88
+ });
89
+ if (error) return c.json({ success: false, error }, 400);
90
+
91
+ // Create an internal Authly session token too
92
+ const token = await createSessionToken({
93
+ sub: session.user?.id ?? "unknown",
94
+ role: session.user?.user_metadata?.role ?? "user",
95
+ });
96
+ return c.json({ success: true, session, token });
97
+ });
98
+
99
+ /** POST /api/auth/session — create a session token from credentials */
100
+ app.post("/api/auth/session", async (c) => {
101
+ const body = await c.req.json();
102
+ if (!body.sub) return c.json({ error: "Missing 'sub' field" }, 400);
103
+ const token = await createSessionToken({
104
+ sub: body.sub,
105
+ role: body.role ?? "user",
106
+ });
107
+ return c.json({ success: true, token });
108
+ });
109
+
110
+ /** GET /api/auth/me — verify session token from Authorization header */
111
+ app.get("/api/auth/me", authMiddleware(), (c) =>
112
+ c.json({ success: true, session: c.get("session") }),
113
+ );
114
+
115
+ // ── Password Auth ──────────────────────────────────
116
+ /** POST /api/auth/register — sign up with email + password */
117
+ app.post("/api/auth/register", async (c) => {
118
+ const body = await c.req.json();
119
+ const { user, token, error } = await signUp({
120
+ email: body.email,
121
+ password: body.password,
122
+ name: body.name ?? "",
123
+ });
124
+ if (error) return c.json({ success: false, error }, 400);
125
+ return c.json({ success: true, user, token });
126
+ });
127
+
128
+ /** POST /api/auth/login — sign in with email + password */
129
+ app.post("/api/auth/login", async (c) => {
130
+ const body = await c.req.json();
131
+ const { user, token, error } = await signIn({
132
+ email: body.email,
133
+ password: body.password,
134
+ });
135
+ if (error) return c.json({ success: false, error }, 401);
136
+ return c.json({ success: true, user, token });
137
+ });
138
+
139
+ /** GET /api/auth/providers — list available and enabled providers */
140
+ app.get("/api/auth/providers", (c) => {
141
+ const providers = getProviders();
142
+ return c.json({ providers });
143
+ });
144
+
145
+ /** GET /api/config — non-sensitive project config */
146
+ app.get("/api/config", async (c) => {
147
+ const fw = detectFramework();
148
+ const { roles = [], error } = await listRoles();
149
+ return c.json({
150
+ framework: fw ?? null,
151
+ providers: listProviderStatus(),
152
+ roles,
153
+ });
154
+ });
155
+
156
+ // ── Role management (protected) ────────────────────
157
+ /** GET /api/roles — list all roles */
158
+ app.get("/api/roles", async (c) => {
159
+ const { roles, error } = await listRoles();
160
+ if (error) return c.json({ success: false, error }, 500);
161
+ return c.json({ success: true, roles });
162
+ });
163
+
164
+ /** POST /api/roles — create a new role */
165
+ app.post("/api/roles", async (c) => {
166
+ const { name, description } = await c.req.json();
167
+ if (!name) return c.json({ error: "'name' is required" }, 400);
168
+ const result = await createRole(name, description ?? "");
169
+ if (!result.success) return c.json(result, 400);
170
+ return c.json(result);
171
+ });
172
+
173
+ /** POST /api/roles/:roleId/users/:userId/assign — assign role to user */
174
+ app.post("/api/roles/:roleName/users/:userId/assign", async (c) => {
175
+ const { roleName, userId } = c.req.param();
176
+ const result = await assignRoleToUser(userId, roleName);
177
+ if (!result.success) return c.json(result, 400);
178
+ return c.json(result);
179
+ });
180
+
181
+ /** DELETE /api/roles/:roleId/users/:userId/revoke — revoke role from user */
182
+ app.delete("/api/roles/:roleName/users/:userId/revoke", async (c) => {
183
+ const { roleName, userId } = c.req.param();
184
+ const result = await revokeRoleFromUser(userId, roleName);
185
+ if (!result.success) return c.json(result, 400);
186
+ return c.json(result);
187
+ });
188
+
189
+ /** GET /api/users/:userId/roles — get roles for a user */
190
+ app.get("/api/users/:userId/roles", async (c) => {
191
+ const { userId } = c.req.param();
192
+ const { roles, error } = await getUserRoles(userId);
193
+ if (error) return c.json({ success: false, error }, 500);
194
+ return c.json({ success: true, userId, roles });
195
+ });
196
+
197
+ // ── API Keys ───────────────────────────────────────
198
+ /** POST /api/keys — generate a new API key */
199
+ app.post("/api/keys", async (c) => {
200
+ const { client, errors } = getSupabaseClient();
201
+ if (!client) return c.json({ success: false, errors }, 503);
202
+
203
+ const body = await c.req.json();
204
+ if (!body.name) return c.json({ error: "'name' is required" }, 400);
205
+
206
+ const rawKey = `authly_${randomBytes(24).toString("base64url")}`;
207
+ const keyHash = createHash("sha256").update(rawKey).digest("hex");
208
+
209
+ const { error } = await client.from("api_keys").insert({
210
+ key_hash: keyHash,
211
+ name: body.name,
212
+ scopes: body.scopes ?? ["read"],
213
+ user_id: body.userId ?? null,
214
+ expires_at: body.expiresAt ?? null,
215
+ });
216
+ if (error) return c.json({ success: false, error: error.message }, 400);
217
+
218
+ // Return raw key ONCE — it cannot be retrieved later
219
+ return c.json({ success: true, key: rawKey, hashesTo: keyHash });
220
+ });
221
+
222
+ // ── Migrations ─────────────────────────────────────
223
+ /** GET /api/migrations — list available migrations */
224
+ app.get("/api/migrations", (c) =>
225
+ c.json({ success: true, migrations: listMigrations() }),
226
+ );
227
+
228
+ /** GET /api/migrations/:name/sql — get SQL for a migration */
229
+ app.get("/api/migrations/:name/sql", (c) => {
230
+ const { name } = c.req.param();
231
+ const sql = getMigration(name);
232
+ if (!sql) return c.json({ error: `Migration '${name}' not found` }, 404);
233
+ return c.json({ success: true, name, sql });
234
+ });
235
+
236
+ /** POST /api/migrations/:name/run — execute a migration against Supabase */
237
+ app.post("/api/migrations/:name/run", async (c) => {
238
+ const { client, errors } = getSupabaseClient();
239
+ if (!client) return c.json({ success: false, errors }, 503);
240
+
241
+ const { name } = c.req.param();
242
+ const sql = getMigration(name);
243
+ if (!sql) return c.json({ error: `Migration '${name}' not found` }, 404);
244
+
245
+ const { error } = await client.rpc("exec_sql", { sql_query: sql });
246
+ if (error) return c.json({ success: false, error: error.message }, 400);
247
+ return c.json({ success: true, migration: name });
248
+ });
249
+
250
+ // ── Scaffold ───────────────────────────────────────
251
+ /** POST /api/scaffold/preview — preview generated code without writing */
252
+ app.post("/api/scaffold/preview", async (c) => {
253
+ const { type } = await c.req.json();
254
+ if (!["login", "signup", "middleware", "route-login", "route-signup"].includes(type)) {
255
+ return c.json({ error: `Unknown type '${type}'. Valid: login, signup, middleware, route-login, route-signup` }, 400);
256
+ }
257
+ return c.json({ success: true, type, code: previewGenerated(type) });
258
+ });
259
+
260
+ /** POST /api/scaffold/generate — write auth files to a project */
261
+ app.post("/api/scaffold/generate", async (c) => {
262
+ const { targetDir } = await c.req.json();
263
+ if (!targetDir) return c.json({ error: "'targetDir' is required" }, 400);
264
+ try {
265
+ const { files } = await scaffoldAuth(targetDir, { apiRoutes: true });
266
+ c.json({ success: true, files });
267
+ } catch (e) {
268
+ c.json({ success: false, error: e.message }, 500);
269
+ }
270
+ });
271
+
272
+ // ── Init ──────────────────────────────────────────
273
+ /** POST /api/init/connect — detect project and generate config */
274
+ app.post("/api/init/connect", async (c) => {
275
+ const body = await c.req.json().catch(() => ({}));
276
+ const fw = detectFramework();
277
+ if (!fw) return c.json({ success: false, error: "No Next.js project detected" }, 400);
278
+
279
+ // Store Supabase config if provided
280
+ if (body.supabaseUrl) process.env.SUPABASE_URL = body.supabaseUrl;
281
+ if (body.supabaseAnonKey) process.env.SUPABASE_ANON_KEY = body.supabaseAnonKey;
282
+ if (body.supabaseServiceKey) process.env.SUPABASE_SERVICE_ROLE_KEY = body.supabaseServiceKey;
283
+
284
+ // Generate .env.local if needed
285
+ if (!fs.existsSync(".env.local")) {
286
+ await generateEnv(".env.local");
287
+ }
288
+
289
+ // Write authly.config.json
290
+ const config = {
291
+ framework: fw,
292
+ supabase: {
293
+ url: body.supabaseUrl || "",
294
+ anonKey: body.supabaseAnonKey ? "set" : "",
295
+ serviceKey: body.supabaseServiceKey ? "set" : "",
296
+ },
297
+ };
298
+ fs.writeFileSync("authly.config.json", JSON.stringify(config, null, 2) + "\n");
299
+
300
+ return c.json({ success: true, framework: fw });
301
+ });
302
+
303
+ // ── MCP (beta) ─────────────────────────────────────
304
+ mountMcp(app);
305
+
306
+ // ── Audit ──────────────────────────────────────────
307
+ app.post("/api/audit", async (c) => {
308
+ const issues = [];
309
+
310
+ // Env vars
311
+ for (const key of ["SUPABASE_URL", "SUPABASE_ANON_KEY", "AUTHLY_SECRET"]) {
312
+ if (!process.env[key]) issues.push({ check: key, status: "fail", detail: "not set" });
313
+ else issues.push({ check: key, status: "ok" });
314
+ }
315
+
316
+ // Supabase connection
317
+ const { client } = getSupabaseClient();
318
+ if (client) {
319
+ const { error } = await client
320
+ .from("auth.users")
321
+ .select("count", { count: "exact", head: true });
322
+ if (error)
323
+ issues.push({ check: "supabase_connection", status: "fail", detail: error.message });
324
+ else
325
+ issues.push({ check: "supabase_connection", status: "ok" });
326
+ } else {
327
+ issues.push({ check: "supabase_connection", status: "fail", detail: "not configured" });
328
+ }
329
+
330
+ const allOk = issues.every((i) => i.status === "ok");
331
+ return c.json({ success: allOk, issues });
332
+ });
333
+
334
+ // ── Root ───────────────────────────────────────────
335
+ app.get("/", (c) => {
336
+ if (hasDashboard) {
337
+ const html = fs.readFileSync(path.join(dashboardPath, "index.html"), "utf-8");
338
+ return c.html(html);
339
+ }
340
+ return c.json({
341
+ name: "authly",
342
+ version: "0.1.0",
343
+ docs: "/api/health",
344
+ });
345
+ });
346
+
347
+ // ── Start server ───────────────────────────────────
348
+ if (hasDashboard) {
349
+ spinner.succeed(
350
+ `Authly dashboard running at ${chalk.cyan(`http://localhost:${PORT}`)}`,
351
+ );
352
+ } else {
353
+ spinner.succeed(
354
+ `Authly API running at ${chalk.cyan(`http://localhost:${PORT}`)}${chalk.dim(" (no dashboard UI)")}`,
355
+ );
356
+ }
357
+ console.log(chalk.dim(" Press Ctrl+C to stop\n"));
358
+
359
+ const server = serve({
360
+ fetch: app.fetch,
361
+ port: Number(PORT),
362
+ });
363
+
364
+ process.on("SIGINT", () => {
365
+ spinner.info("Shutting down authly dashboard");
366
+ server.close();
367
+ process.exit(0);
368
+ });
369
+ }
370
+
371
+ function getContentType(filePath) {
372
+ const ext = path.extname(filePath);
373
+ const types = {
374
+ ".html": "text/html",
375
+ ".css": "text/css",
376
+ ".js": "text/javascript",
377
+ ".json": "application/json",
378
+ ".png": "image/png",
379
+ ".svg": "image/svg+xml",
380
+ ".ico": "image/x-icon",
381
+ };
382
+ return types[ext] || "application/octet-stream";
383
+ }
@@ -0,0 +1,37 @@
1
+ import fs from "node:fs";
2
+
3
+ /**
4
+ * Generate a .env.local template with all authly-related variables.
5
+ */
6
+ export async function generateEnv(envPath) {
7
+ const template = `# Authly Configuration - Generated by authly init
8
+ # https://github.com/rblez/authly
9
+
10
+ # Supabase (required)
11
+ # Get these from your Supabase project settings
12
+ SUPABASE_URL=""
13
+ SUPABASE_ANON_KEY=""
14
+ SUPABASE_SERVICE_ROLE_KEY=""
15
+
16
+ # Authly (required)
17
+ # Random secret for signing internal JWT tokens
18
+ # Generate with: openssl rand -hex 32
19
+ AUTHLY_SECRET=""
20
+
21
+ # OAuth Providers (optional)
22
+ # Google OAuth
23
+ GOOGLE_CLIENT_ID=""
24
+ GOOGLE_CLIENT_SECRET=""
25
+
26
+ # GitHub OAuth
27
+ GITHUB_CLIENT_ID=""
28
+ GITHUB_CLIENT_SECRET=""
29
+
30
+ # Magic Link (optional)
31
+ # RESEND_API_KEY=""
32
+
33
+ # Dashboard (optional overrides)
34
+ # AUTHLY_PORT=1284
35
+ `;
36
+ fs.writeFileSync(envPath, template, "utf-8");
37
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * SQL migration templates for Authly's Supabase schema.
3
+ */
4
+
5
+ export const migrations = [
6
+ {
7
+ name: "001_create_roles_table",
8
+ description: "Core RBAC — role definitions in database",
9
+ sql: `
10
+ CREATE TABLE IF NOT EXISTS public.roles (
11
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
12
+ name text UNIQUE NOT NULL,
13
+ description text DEFAULT '',
14
+ created_at timestamptz DEFAULT now(),
15
+ updated_at timestamptz DEFAULT now()
16
+ );
17
+
18
+ INSERT INTO public.roles (name, description) VALUES
19
+ ('admin', 'Full access to all resources'),
20
+ ('user', 'Standard authenticated user'),
21
+ ('guest', 'Limited access, no write permissions')
22
+ ON CONFLICT (name) DO NOTHING;
23
+ `,
24
+ },
25
+ {
26
+ name: "002_create_authly_users_table",
27
+ description: "Authly user pool — manages users independent of Supabase auth",
28
+ sql: `
29
+ CREATE TABLE IF NOT EXISTS public.authly_users (
30
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
31
+ email text UNIQUE NOT NULL,
32
+ password_hash text, -- NULL for OAuth-only users
33
+ name text DEFAULT '',
34
+ avatar_url text DEFAULT '',
35
+ email_verified boolean DEFAULT false,
36
+ created_at timestamptz DEFAULT now(),
37
+ updated_at timestamptz DEFAULT now()
38
+ );
39
+
40
+ CREATE INDEX idx_authly_users_email ON public.authly_users(email);
41
+ `,
42
+ },
43
+ {
44
+ name: "003_create_oauth_accounts_table",
45
+ description: "Links OAuth providers to authly_users",
46
+ sql: `
47
+ CREATE TABLE IF NOT EXISTS public.authly_oauth_accounts (
48
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
49
+ user_id uuid NOT NULL REFERENCES public.authly_users(id) ON DELETE CASCADE,
50
+ provider text NOT NULL, -- 'google', 'github', 'discord'
51
+ provider_id text NOT NULL, -- provider's user ID
52
+ access_token text,
53
+ refresh_token text,
54
+ expires_at timestamptz,
55
+ created_at timestamptz DEFAULT now(),
56
+ updated_at timestamptz DEFAULT now(),
57
+ UNIQUE(provider, provider_id)
58
+ );
59
+
60
+ CREATE INDEX idx_oauth_user_id ON public.authly_oauth_accounts(user_id);
61
+ `,
62
+ },
63
+ {
64
+ name: "004_create_user_roles_table",
65
+ description: "RBAC — link authly_users to roles",
66
+ sql: `
67
+ CREATE TABLE IF NOT EXISTS public.authly_user_roles (
68
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
69
+ user_id uuid NOT NULL REFERENCES public.authly_users(id) ON DELETE CASCADE,
70
+ role_id uuid NOT NULL REFERENCES public.roles(id) ON DELETE CASCADE,
71
+ assigned_at timestamptz DEFAULT now(),
72
+ UNIQUE(user_id, role_id)
73
+ );
74
+
75
+ -- Trigger: auto-assign 'user' role on new user
76
+ CREATE OR REPLACE FUNCTION public.authly_assign_default_role()
77
+ RETURNS trigger AS $$
78
+ BEGIN
79
+ INSERT INTO public.authly_user_roles (user_id, role_id)
80
+ SELECT NEW.id, r.id
81
+ FROM public.roles r
82
+ WHERE r.name = 'user'
83
+ ON CONFLICT DO NOTHING;
84
+ RETURN NEW;
85
+ END;
86
+ $$ LANGUAGE plpgsql SECURITY DEFINER;
87
+
88
+ CREATE OR REPLACE TRIGGER on_authly_user_created
89
+ AFTER INSERT ON public.authly_users
90
+ FOR EACH ROW
91
+ EXECUTE FUNCTION public.authly_assign_default_role();
92
+ `,
93
+ },
94
+ {
95
+ name: "005_create_api_keys_table",
96
+ description: "Programmatic access keys with scopes",
97
+ sql: `
98
+ CREATE TABLE IF NOT EXISTS public.authly_api_keys (
99
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
100
+ key_hash text UNIQUE NOT NULL,
101
+ name text NOT NULL,
102
+ scopes text[] DEFAULT '{read}',
103
+ user_id uuid REFERENCES public.authly_users(id) ON DELETE SET NULL,
104
+ created_at timestamptz DEFAULT now(),
105
+ expires_at timestamptz,
106
+ revoked_at timestamptz
107
+ );
108
+
109
+ CREATE INDEX idx_api_keys_hash ON public.authly_api_keys(key_hash);
110
+ `,
111
+ },
112
+ {
113
+ name: "006_create_user_sessions_table",
114
+ description: "Session tracking for active users",
115
+ sql: `
116
+ CREATE TABLE IF NOT EXISTS public.authly_sessions (
117
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
118
+ user_id uuid NOT NULL REFERENCES public.authly_users(id) ON DELETE CASCADE,
119
+ token_hash text UNIQUE NOT NULL,
120
+ ip_address text,
121
+ user_agent text,
122
+ expires_at timestamptz NOT NULL,
123
+ created_at timestamptz DEFAULT now()
124
+ );
125
+
126
+ CREATE INDEX idx_sessions_user ON public.authly_sessions(user_id);
127
+ CREATE INDEX idx_sessions_token ON public.authly_sessions(token_hash);
128
+ `,
129
+ },
130
+ ];
131
+
132
+ export function getMigration(name) {
133
+ return migrations.find((m) => m.name === name)?.sql ?? null;
134
+ }
135
+
136
+ export function listMigrations() {
137
+ return migrations.map(({ name, description }) => ({ name, description }));
138
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Role management — authly_users + roles tables.
3
+ */
4
+
5
+ import { createClient } from "@supabase/supabase-js";
6
+
7
+ function _getClient() {
8
+ const url = process.env.SUPABASE_URL;
9
+ const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_ANON_KEY;
10
+ if (!url || !key) return { client: null, errors: ["Supabase not configured"] };
11
+ return { client: createClient(url, key), errors: [] };
12
+ }
13
+
14
+ export async function createRole(name, description = "") {
15
+ const { client, errors } = _getClient();
16
+ if (!client) return { success: false, error: errors.join("; ") };
17
+
18
+ const { error } = await client.from("roles").insert({ name, description });
19
+ if (error) return { success: false, error: error.message };
20
+ return { success: true, error: null };
21
+ }
22
+
23
+ export async function assignRoleToUser(userId, roleName) {
24
+ const { client, errors } = _getClient();
25
+ if (!client) return { success: false, error: errors.join("; ") };
26
+
27
+ const { data: role } = await client.from("roles").select("id").eq("name", roleName).single();
28
+ if (!role) return { success: false, error: `Role '${roleName}' not found` };
29
+
30
+ const { error } = await client.from("authly_user_roles").upsert({ user_id: userId, role_id: role.id });
31
+ if (error) return { success: false, error: error.message };
32
+ return { success: true, error: null };
33
+ }
34
+
35
+ export async function revokeRoleFromUser(userId, roleName) {
36
+ const { client, errors } = _getClient();
37
+ if (!client) return { success: false, error: errors.join("; ") };
38
+
39
+ const { data: role } = await client.from("roles").select("id").eq("name", roleName).single();
40
+ if (!role) return { success: false, error: `Role '${roleName}' not found` };
41
+
42
+ const { error } = await client.from("authly_user_roles").delete().eq("user_id", userId).eq("role_id", role.id);
43
+ if (error) return { success: false, error: error.message };
44
+ return { success: true, error: null };
45
+ }
46
+
47
+ export async function getUserRoles(userId) {
48
+ const { client, errors } = _getClient();
49
+ if (!client) return { roles: [], error: errors.join("; ") };
50
+
51
+ const { data, error } = await client
52
+ .from("authly_user_roles")
53
+ .select("roles(name)")
54
+ .eq("user_id", userId);
55
+
56
+ if (error) return { roles: [], error: error.message };
57
+ return { roles: data.map((d) => d.roles?.name ?? "").filter(Boolean), error: null };
58
+ }
59
+
60
+ export async function listRoles() {
61
+ const { client, errors } = _getClient();
62
+ if (!client) return { roles: [], error: errors.join("; ") };
63
+
64
+ const { data, error } = await client.from("roles").select("name, description").order("name");
65
+ if (error) return { roles: [], error: error.message };
66
+ return { roles: data ?? [], error: null };
67
+ }