@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.
- package/README.md +215 -0
- package/bin/authly.js +53 -0
- package/dist/dashboard/app.js +326 -0
- package/dist/dashboard/index.html +238 -0
- package/dist/dashboard/styles.css +742 -0
- package/package.json +48 -0
- package/src/auth/index.js +134 -0
- package/src/commands/audit.js +82 -0
- package/src/commands/init.js +67 -0
- package/src/commands/serve.js +383 -0
- package/src/generators/env.js +37 -0
- package/src/generators/migrations.js +138 -0
- package/src/generators/roles.js +67 -0
- package/src/generators/ui.js +619 -0
- package/src/lib/framework.js +29 -0
- package/src/lib/jwt.js +107 -0
- package/src/lib/oauth.js +301 -0
- package/src/lib/supabase.js +58 -0
- package/src/mcp/server.js +281 -0
package/src/lib/jwt.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { SignJWT, jwtVerify } from "jose";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Internal JWT utilities for Authly's own session management.
|
|
5
|
+
* Uses jose (native ESM, no native deps — works on Termux).
|
|
6
|
+
*
|
|
7
|
+
* Tokens are signed with AUTHLY_SECRET (HS256) and carry
|
|
8
|
+
* a minimal payload: { sub, role, iat, exp }.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const ALG = "HS256";
|
|
12
|
+
const DEFAULT_TTL = 1000 * 60 * 60 * 24; // 24h
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get a CryptoKey from the AUTHLY_SECRET env var.
|
|
16
|
+
*
|
|
17
|
+
* @returns {Promise<CryptoKey>}
|
|
18
|
+
*/
|
|
19
|
+
async function getSigningKey() {
|
|
20
|
+
const secret = process.env.AUTHLY_SECRET;
|
|
21
|
+
if (!secret) {
|
|
22
|
+
throw new Error("AUTHLY_SECRET is not configured");
|
|
23
|
+
}
|
|
24
|
+
return new TextEncoder().encode(secret);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a signed JWT for internal authly sessions.
|
|
29
|
+
*
|
|
30
|
+
* @param {{ sub: string; role: string }} payload
|
|
31
|
+
* @param {{ ttl?: number }} [options]
|
|
32
|
+
* @returns {Promise<string>}
|
|
33
|
+
*/
|
|
34
|
+
export async function createSessionToken(payload, options = {}) {
|
|
35
|
+
const secret = await getSigningKey();
|
|
36
|
+
const ttl = options.ttl ?? DEFAULT_TTL;
|
|
37
|
+
|
|
38
|
+
return new SignJWT({ role: payload.role })
|
|
39
|
+
.setProtectedHeader({ alg: ALG })
|
|
40
|
+
.setSubject(payload.sub)
|
|
41
|
+
.setIssuedAt()
|
|
42
|
+
.setExpirationTime(Date.now() + ttl)
|
|
43
|
+
.sign(secret);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Verify and decode an internal session token.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} token
|
|
50
|
+
* @returns {Promise<{ sub: string; role: string } | null>}
|
|
51
|
+
*/
|
|
52
|
+
export async function verifySessionToken(token) {
|
|
53
|
+
const secret = await getSigningKey();
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const { payload } = await jwtVerify(token, secret);
|
|
57
|
+
return {
|
|
58
|
+
sub: payload.sub,
|
|
59
|
+
role: payload.role ?? "user",
|
|
60
|
+
};
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create a bearer middleware for hono that validates the
|
|
68
|
+
* Authorization header against our internal session tokens.
|
|
69
|
+
*
|
|
70
|
+
* @returns {import("hono").MiddlewareHandler}
|
|
71
|
+
*/
|
|
72
|
+
export function authMiddleware() {
|
|
73
|
+
return async (c, next) => {
|
|
74
|
+
const header = c.req.header("Authorization");
|
|
75
|
+
if (!header?.startsWith("Bearer ")) {
|
|
76
|
+
return c.json({ error: "Missing or invalid Authorization header" }, 401);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const session = await verifySessionToken(header.slice(7));
|
|
80
|
+
if (!session) {
|
|
81
|
+
return c.json({ error: "Invalid or expired token" }, 401);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
c.set("session", session);
|
|
85
|
+
return next();
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Create a role-check wrapper for hono handlers.
|
|
91
|
+
* Requires a valid session with the specified role.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} requiredRole
|
|
94
|
+
* @returns {import("hono").MiddlewareHandler}
|
|
95
|
+
*/
|
|
96
|
+
export function requireRole(requiredRole) {
|
|
97
|
+
return async (c, next) => {
|
|
98
|
+
const session = c.get("session");
|
|
99
|
+
if (!session) {
|
|
100
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
101
|
+
}
|
|
102
|
+
if (session.role !== requiredRole && session.role !== "admin") {
|
|
103
|
+
return c.json({ error: "Forbidden: insufficient permissions" }, 403);
|
|
104
|
+
}
|
|
105
|
+
return next();
|
|
106
|
+
};
|
|
107
|
+
}
|
package/src/lib/oauth.js
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authly OAuth Provider SDK
|
|
3
|
+
*
|
|
4
|
+
* Handles the full OAuth2 authorization code flow directly —
|
|
5
|
+
* no Supabase auth dependency. Like next-auth but as a standalone
|
|
6
|
+
* SDK that works with Supabase for user storage.
|
|
7
|
+
*
|
|
8
|
+
* Flow:
|
|
9
|
+
* 1. buildAuthorizeUrl() → redirect user to provider
|
|
10
|
+
* 2. User authorizes → provider redirects to callback
|
|
11
|
+
* 3. exchangeTokens() → get access_token from provider
|
|
12
|
+
* 4. fetchUserInfo() → get email/name from provider
|
|
13
|
+
* 5. upsertUser() → create/update user in Supabase
|
|
14
|
+
* 6. createSessionToken() → Authly JWT session
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createClient } from "@supabase/supabase-js";
|
|
18
|
+
|
|
19
|
+
const PROVIDERS = {
|
|
20
|
+
google: {
|
|
21
|
+
authorize: "https://accounts.google.com/o/oauth2/v2/auth",
|
|
22
|
+
token: "https://oauth2.googleapis.com/token",
|
|
23
|
+
userinfo: "https://www.googleapis.com/oauth2/v2/userinfo",
|
|
24
|
+
scopeDefault: "openid email profile",
|
|
25
|
+
},
|
|
26
|
+
github: {
|
|
27
|
+
authorize: "https://github.com/login/oauth/authorize",
|
|
28
|
+
token: "https://github.com/login/oauth/access_token",
|
|
29
|
+
userinfo: "https://api.github.com/user",
|
|
30
|
+
scopeDefault: "read:user user:email",
|
|
31
|
+
},
|
|
32
|
+
discord: {
|
|
33
|
+
authorize: "https://discord.com/api/oauth2/authorize",
|
|
34
|
+
token: "https://discord.com/api/oauth2/token",
|
|
35
|
+
userinfo: "https://discord.com/api/users/@me",
|
|
36
|
+
scopeDefault: "identify email",
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build the OAuth authorization URL.
|
|
42
|
+
*
|
|
43
|
+
* @param {{ provider: string; redirectUri: string; state?: string; scope?: string }} opts
|
|
44
|
+
* @returns {{ url: string; state: string }}
|
|
45
|
+
*/
|
|
46
|
+
export function buildAuthorizeUrl(opts) {
|
|
47
|
+
const config = PROVIDERS[opts.provider.toLowerCase()];
|
|
48
|
+
if (!config) throw new Error(`Unknown provider: ${opts.provider}`);
|
|
49
|
+
|
|
50
|
+
const state = opts.state || crypto.randomUUID();
|
|
51
|
+
|
|
52
|
+
const clientId = process.env[`${opts.provider.toUpperCase()}_CLIENT_ID`];
|
|
53
|
+
if (!clientId) throw new Error(`${opts.provider} CLIENT_ID not configured`);
|
|
54
|
+
|
|
55
|
+
const params = new URLSearchParams({
|
|
56
|
+
client_id: clientId,
|
|
57
|
+
redirect_uri: opts.redirectUri,
|
|
58
|
+
response_type: "code",
|
|
59
|
+
scope: opts.scope || config.scopeDefault,
|
|
60
|
+
state,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (opts.provider.toLowerCase() === "google") {
|
|
64
|
+
params.set("access_type", "offline");
|
|
65
|
+
params.set("prompt", "consent");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { url: `${config.authorize}?${params.toString()}`, state };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Exchange authorization code for an access token.
|
|
73
|
+
*
|
|
74
|
+
* @param {{ provider: string; code: string; redirectUri: string }} opts
|
|
75
|
+
* @returns {Promise<{ accessToken: string; scope: string; extra: Record<string, unknown> }>}
|
|
76
|
+
*/
|
|
77
|
+
export async function exchangeTokens(opts) {
|
|
78
|
+
const config = PROVIDERS[opts.provider.toLowerCase()];
|
|
79
|
+
if (!config) throw new Error(`Unknown provider: ${opts.provider}`);
|
|
80
|
+
|
|
81
|
+
const clientId = process.env[`${opts.provider.toUpperCase()}_CLIENT_ID`];
|
|
82
|
+
const clientSecret = process.env[`${opts.provider.toUpperCase()}_CLIENT_SECRET`];
|
|
83
|
+
if (!clientId || !clientSecret) {
|
|
84
|
+
throw new Error(`${opts.provider} CLIENT_ID and CLIENT_SECRET must be configured`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const body = new URLSearchParams({
|
|
88
|
+
code: opts.code,
|
|
89
|
+
client_id: clientId,
|
|
90
|
+
client_secret: clientSecret,
|
|
91
|
+
grant_type: "authorization_code",
|
|
92
|
+
redirect_uri: opts.redirectUri,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const headers = {
|
|
96
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
97
|
+
Accept: "application/json",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// GitHub requires Accept header; Google doesn't want it in some cases
|
|
101
|
+
if (opts.provider.toLowerCase() === "github") {
|
|
102
|
+
headers.Accept = "application/json";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const res = await fetch(config.token, { method: "POST", headers, body });
|
|
106
|
+
|
|
107
|
+
if (!res.ok) {
|
|
108
|
+
const text = await res.text();
|
|
109
|
+
throw new Error(`Token exchange failed (${res.status}): ${text.slice(0, 300)}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const data = await res.json();
|
|
113
|
+
|
|
114
|
+
if (data.error) {
|
|
115
|
+
throw new Error(`OAuth error: ${data.error} — ${data.error_description || ""}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
accessToken: data.access_token,
|
|
120
|
+
refreshToken: data.refresh_token,
|
|
121
|
+
expiresIn: data.expires_in,
|
|
122
|
+
scope: data.scope || "",
|
|
123
|
+
extra: data,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Fetch user info (email, name, avatar) from the provider.
|
|
129
|
+
*
|
|
130
|
+
* @param {{ provider: string; accessToken: string }} opts
|
|
131
|
+
* @returns {Promise<{ email: string; name?: string; avatar?: string; id?: string }>}
|
|
132
|
+
*/
|
|
133
|
+
export async function fetchUserInfo(opts) {
|
|
134
|
+
const config = PROVIDERS[opts.provider.toLowerCase()];
|
|
135
|
+
if (!config) throw new Error(`Unknown provider: ${opts.provider}`);
|
|
136
|
+
|
|
137
|
+
const headers = {
|
|
138
|
+
Authorization: `Bearer ${opts.accessToken}`,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
if (opts.provider.toLowerCase() === "github") {
|
|
142
|
+
headers.Accept = "application/vnd.github+json";
|
|
143
|
+
headers["X-GitHub-Api-Version"] = "2022-11-28";
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const res = await fetch(config.userinfo, { headers });
|
|
147
|
+
if (!res.ok) throw new Error(`Userinfo failed (${res.status})`);
|
|
148
|
+
|
|
149
|
+
const data = await res.json();
|
|
150
|
+
|
|
151
|
+
// Normalize across providers
|
|
152
|
+
return {
|
|
153
|
+
providerId: String(data.sub ?? data.id ?? ""),
|
|
154
|
+
email: String(
|
|
155
|
+
data.email ??
|
|
156
|
+
(data.emails?.[0]?.email) ??
|
|
157
|
+
""
|
|
158
|
+
),
|
|
159
|
+
name: String(data.name ?? data.display_name ?? data.login ?? data.username ?? ""),
|
|
160
|
+
avatar: String(data.picture ?? data.avatar_url ?? data.avatar ?? ""),
|
|
161
|
+
raw: data,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Upsert the user into Supabase's auth.users table via the
|
|
167
|
+
* Management API, then return the Supabase user record.
|
|
168
|
+
*
|
|
169
|
+
* If the user doesn't exist, creates them with the provider
|
|
170
|
+
* linked. If they exist with the same email, links the provider.
|
|
171
|
+
*
|
|
172
|
+
* @param {{ provider: string; providerId: string; email: string; name?: string; avatar?: string }} opts
|
|
173
|
+
* @returns {Promise<{ userId: string; email: string; isNew: boolean }>}
|
|
174
|
+
*/
|
|
175
|
+
export async function upsertUser(opts) {
|
|
176
|
+
const { client, errors } = _getClient();
|
|
177
|
+
if (!client) throw new Error(`Supabase not configured: ${errors.join(", ")}`);
|
|
178
|
+
|
|
179
|
+
// Check if user already exists by email
|
|
180
|
+
const { data: existing } = await client
|
|
181
|
+
.from("user_profiles")
|
|
182
|
+
.select("user_id")
|
|
183
|
+
.eq("email", opts.email)
|
|
184
|
+
.single();
|
|
185
|
+
|
|
186
|
+
if (existing) {
|
|
187
|
+
// Update profile
|
|
188
|
+
await client
|
|
189
|
+
.from("user_profiles")
|
|
190
|
+
.update({
|
|
191
|
+
avatar_url: opts.avatar || "",
|
|
192
|
+
updated_at: new Date().toISOString(),
|
|
193
|
+
provider: opts.provider,
|
|
194
|
+
provider_id: opts.providerId,
|
|
195
|
+
})
|
|
196
|
+
.eq("user_id", existing.user_id);
|
|
197
|
+
|
|
198
|
+
return { userId: existing.user_id, email: opts.email, isNew: false };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Create user — since we can't directly insert into auth.users,
|
|
202
|
+
// we use the Supabase admin createUser method
|
|
203
|
+
// For now, we insert into user_profiles and let the app handle
|
|
204
|
+
// the auth.users linkage via a database function
|
|
205
|
+
const { data: newUser, error } = await client
|
|
206
|
+
.from("user_profiles")
|
|
207
|
+
.insert({
|
|
208
|
+
email: opts.email,
|
|
209
|
+
full_name: opts.name || "",
|
|
210
|
+
avatar_url: opts.avatar || "",
|
|
211
|
+
provider: opts.provider,
|
|
212
|
+
provider_id: opts.providerId,
|
|
213
|
+
})
|
|
214
|
+
.select("user_id")
|
|
215
|
+
.single();
|
|
216
|
+
|
|
217
|
+
if (error) throw new Error(`Failed to create user: ${error.message}`);
|
|
218
|
+
|
|
219
|
+
return { userId: newUser.user_id, email: opts.email, isNew: true };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Full OAuth login flow — all steps in one call.
|
|
224
|
+
* Used by the Next.js auth callback route.
|
|
225
|
+
*
|
|
226
|
+
* @param {{ provider: string; code: string; redirectUri: string }} opts
|
|
227
|
+
* @returns {Promise<{ userId: string; email: string; token: string; isNew: boolean }>}
|
|
228
|
+
*/
|
|
229
|
+
export async function authWithProvider(opts) {
|
|
230
|
+
const tokens = await exchangeTokens(opts);
|
|
231
|
+
const info = await fetchUserInfo({ provider: opts.provider, accessToken: tokens.accessToken });
|
|
232
|
+
|
|
233
|
+
if (!info.email && !info.providerId) {
|
|
234
|
+
throw new Error("Provider returned no email and no ID");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const user = await upsertUser({
|
|
238
|
+
provider: opts.provider,
|
|
239
|
+
providerId: info.providerId,
|
|
240
|
+
email: info.email,
|
|
241
|
+
name: info.name,
|
|
242
|
+
avatar: info.avatar,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
userId: user.userId,
|
|
247
|
+
email: user.email,
|
|
248
|
+
isNew: user.isNew,
|
|
249
|
+
provider: opts.provider,
|
|
250
|
+
tokens,
|
|
251
|
+
info: {
|
|
252
|
+
name: info.name,
|
|
253
|
+
avatar: info.avatar,
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* List all configured providers and their status.
|
|
260
|
+
*
|
|
261
|
+
* @returns {{ name: string; enabled: boolean; scopes: string }[]}
|
|
262
|
+
*/
|
|
263
|
+
export function listProviderStatus() {
|
|
264
|
+
return Object.keys(PROVIDERS).map((name) => {
|
|
265
|
+
const upper = name.toUpperCase();
|
|
266
|
+
const clientId = process.env[`${upper}_CLIENT_ID`] || "";
|
|
267
|
+
const clientSecret = process.env[`${upper}_CLIENT_SECRET`] || "";
|
|
268
|
+
return {
|
|
269
|
+
name,
|
|
270
|
+
enabled: !!(clientId && clientSecret),
|
|
271
|
+
scopes: PROVIDERS[name].scopeDefault,
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Add a custom provider at runtime.
|
|
278
|
+
*
|
|
279
|
+
* @param {{ name: string; authorize: string; token: string; userinfo: string; scope: string }} config
|
|
280
|
+
*/
|
|
281
|
+
export function addProvider(config) {
|
|
282
|
+
PROVIDERS[config.name.toLowerCase()] = {
|
|
283
|
+
authorize: config.authorize,
|
|
284
|
+
token: config.token,
|
|
285
|
+
userinfo: config.userinfo,
|
|
286
|
+
scopeDefault: config.scope,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ── Internal helpers ──────────────────────────────────
|
|
291
|
+
|
|
292
|
+
function _getClient() {
|
|
293
|
+
const url = process.env.SUPABASE_URL;
|
|
294
|
+
const key = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_ANON_KEY;
|
|
295
|
+
|
|
296
|
+
if (!url || !key) {
|
|
297
|
+
return { client: null, errors: ["Supabase credentials not configured"] };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { client: createClient(url, key), errors: [] };
|
|
301
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createClient } from "@supabase/supabase-js";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export function getSupabaseClient() {
|
|
6
|
+
const errors = [];
|
|
7
|
+
let url = process.env.SUPABASE_URL;
|
|
8
|
+
let anonKey = process.env.SUPABASE_ANON_KEY;
|
|
9
|
+
let serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
10
|
+
|
|
11
|
+
if (!url || !anonKey) {
|
|
12
|
+
const config = _loadConfig();
|
|
13
|
+
if (config) {
|
|
14
|
+
url ||= config.supabase?.url || "";
|
|
15
|
+
anonKey ||= config.supabase?.anonKey || "";
|
|
16
|
+
serviceKey ||= config.supabase?.serviceRoleKey || "";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (!url) errors.push("SUPABASE_URL is not configured");
|
|
21
|
+
if (!anonKey) errors.push("SUPABASE_ANON_KEY is not configured");
|
|
22
|
+
if (errors.length > 0) return { client: null, errors };
|
|
23
|
+
|
|
24
|
+
const client = createClient(url, anonKey, {
|
|
25
|
+
global: {
|
|
26
|
+
headers: serviceKey
|
|
27
|
+
? { apikey: serviceKey, Authorization: `Bearer ${serviceKey}` }
|
|
28
|
+
: {},
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return { client, errors: [] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* List users from the authly_users table.
|
|
37
|
+
* This is the primary user source — not Supabase auth.users.
|
|
38
|
+
*/
|
|
39
|
+
export async function fetchUsers({ limit = 50 } = {}) {
|
|
40
|
+
const { client, errors } = getSupabaseClient();
|
|
41
|
+
if (!client) return { users: [], error: errors.join(", ") };
|
|
42
|
+
|
|
43
|
+
const { data, error } = await client
|
|
44
|
+
.from("authly_users")
|
|
45
|
+
.select("id, email, name, avatar_url, email_verified, created_at")
|
|
46
|
+
.order("created_at", { ascending: false })
|
|
47
|
+
.limit(limit);
|
|
48
|
+
|
|
49
|
+
if (error) return { users: [], error: error.message };
|
|
50
|
+
return { users: data ?? [], error: null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function _loadConfig() {
|
|
54
|
+
const p = path.join(process.cwd(), "authly.config.json");
|
|
55
|
+
if (!fs.existsSync(p)) return null;
|
|
56
|
+
try { return JSON.parse(fs.readFileSync(p, "utf-8")); }
|
|
57
|
+
catch { return null; }
|
|
58
|
+
}
|