@fragno-dev/auth 0.0.14
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/LICENSE.md +16 -0
- package/README.md +16 -0
- package/dist/browser/client/react.d.ts +122 -0
- package/dist/browser/client/react.d.ts.map +1 -0
- package/dist/browser/client/react.js +11 -0
- package/dist/browser/client/react.js.map +1 -0
- package/dist/browser/client/solid.d.ts +122 -0
- package/dist/browser/client/solid.d.ts.map +1 -0
- package/dist/browser/client/solid.js +11 -0
- package/dist/browser/client/solid.js.map +1 -0
- package/dist/browser/client/svelte.d.ts +122 -0
- package/dist/browser/client/svelte.d.ts.map +1 -0
- package/dist/browser/client/svelte.js +11 -0
- package/dist/browser/client/svelte.js.map +1 -0
- package/dist/browser/client/vanilla.d.ts +122 -0
- package/dist/browser/client/vanilla.d.ts.map +1 -0
- package/dist/browser/client/vanilla.js +11 -0
- package/dist/browser/client/vanilla.js.map +1 -0
- package/dist/browser/client/vue.d.ts +122 -0
- package/dist/browser/client/vue.d.ts.map +1 -0
- package/dist/browser/client/vue.js +11 -0
- package/dist/browser/client/vue.js.map +1 -0
- package/dist/browser/index.d.ts +600 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +3 -0
- package/dist/browser/src-DNrh9CQq.js +184 -0
- package/dist/browser/src-DNrh9CQq.js.map +1 -0
- package/dist/node/index.d.ts +600 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +677 -0
- package/dist/node/index.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +97 -0
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
import { defineFragment, defineRoute, defineRoutes, instantiate } from "@fragno-dev/core";
|
|
2
|
+
import { createClientBuilder } from "@fragno-dev/core/client";
|
|
3
|
+
import { withDatabase } from "@fragno-dev/db";
|
|
4
|
+
import { column, idColumn, referenceColumn, schema } from "@fragno-dev/db/schema";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { decodeCursor } from "@fragno-dev/db/cursor";
|
|
7
|
+
|
|
8
|
+
//#region src/schema.ts
|
|
9
|
+
const authSchema = schema("auth", (s) => {
|
|
10
|
+
return s.addTable("user", (t) => {
|
|
11
|
+
return t.addColumn("id", idColumn()).addColumn("email", column("string")).addColumn("passwordHash", column("string")).addColumn("role", column("string").defaultTo("user")).addColumn("createdAt", column("timestamp").defaultTo((b) => b.now())).createIndex("idx_user_email", ["email"]).createIndex("idx_user_id", ["id"], { unique: true });
|
|
12
|
+
}).addTable("session", (t) => {
|
|
13
|
+
return t.addColumn("id", idColumn()).addColumn("userId", referenceColumn()).addColumn("expiresAt", column("timestamp")).addColumn("createdAt", column("timestamp").defaultTo((b) => b.now())).createIndex("idx_session_user", ["userId"]);
|
|
14
|
+
}).addReference("sessionOwner", {
|
|
15
|
+
from: {
|
|
16
|
+
table: "session",
|
|
17
|
+
column: "userId"
|
|
18
|
+
},
|
|
19
|
+
to: {
|
|
20
|
+
table: "user",
|
|
21
|
+
column: "id"
|
|
22
|
+
},
|
|
23
|
+
type: "one"
|
|
24
|
+
}).alterTable("user", (t) => {
|
|
25
|
+
return t.createIndex("idx_user_createdAt", ["createdAt"]);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/user/password.ts
|
|
31
|
+
async function hashPassword(password) {
|
|
32
|
+
const encoder = new TextEncoder();
|
|
33
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
34
|
+
const iterations = 1e5;
|
|
35
|
+
const keyMaterial = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"]);
|
|
36
|
+
const hashBuffer = await crypto.subtle.deriveBits({
|
|
37
|
+
name: "PBKDF2",
|
|
38
|
+
salt,
|
|
39
|
+
iterations,
|
|
40
|
+
hash: "SHA-256"
|
|
41
|
+
}, keyMaterial, 256);
|
|
42
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
43
|
+
const saltArray = Array.from(salt);
|
|
44
|
+
return `${saltArray.map((b) => b.toString(16).padStart(2, "0")).join("")}:${iterations}:${hashArray.map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
45
|
+
}
|
|
46
|
+
async function verifyPassword(password, storedHash) {
|
|
47
|
+
const parts = storedHash.split(":");
|
|
48
|
+
if (parts.length !== 3) return false;
|
|
49
|
+
const [saltHex, iterationsStr, hashHex] = parts;
|
|
50
|
+
const iterations = Number.parseInt(iterationsStr, 10);
|
|
51
|
+
const isHex = (value) => /^[0-9a-f]+$/i.test(value) && value.length % 2 === 0;
|
|
52
|
+
if (!saltHex || !hashHex || !isHex(saltHex) || !isHex(hashHex)) return false;
|
|
53
|
+
if (!Number.isFinite(iterations) || iterations <= 0) return false;
|
|
54
|
+
const saltPairs = saltHex.match(/.{1,2}/g);
|
|
55
|
+
const hashPairs = hashHex.match(/.{1,2}/g);
|
|
56
|
+
if (!saltPairs || !hashPairs) return false;
|
|
57
|
+
const salt = new Uint8Array(saltPairs.map((byte) => Number.parseInt(byte, 16)));
|
|
58
|
+
const storedHashBytes = new Uint8Array(hashPairs.map((byte) => Number.parseInt(byte, 16)));
|
|
59
|
+
const encoder = new TextEncoder();
|
|
60
|
+
const keyMaterial = await crypto.subtle.importKey("raw", encoder.encode(password), "PBKDF2", false, ["deriveBits"]);
|
|
61
|
+
const hashBuffer = await crypto.subtle.deriveBits({
|
|
62
|
+
name: "PBKDF2",
|
|
63
|
+
salt,
|
|
64
|
+
iterations,
|
|
65
|
+
hash: "SHA-256"
|
|
66
|
+
}, keyMaterial, 256);
|
|
67
|
+
const hashArray = new Uint8Array(hashBuffer);
|
|
68
|
+
if (hashArray.length !== storedHashBytes.length) return false;
|
|
69
|
+
let isEqual = true;
|
|
70
|
+
for (let i = 0; i < hashArray.length; i++) if (hashArray[i] !== storedHashBytes[i]) isEqual = false;
|
|
71
|
+
return isEqual;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/utils/cookie.ts
|
|
76
|
+
/**
|
|
77
|
+
* Cookie utilities for session management
|
|
78
|
+
*/
|
|
79
|
+
const COOKIE_NAME = "sessionid";
|
|
80
|
+
const MAX_AGE = 2592e3;
|
|
81
|
+
/**
|
|
82
|
+
* Parse cookies from a Cookie header string
|
|
83
|
+
*/
|
|
84
|
+
function parseCookies(cookieHeader) {
|
|
85
|
+
if (!cookieHeader) return {};
|
|
86
|
+
const cookies = {};
|
|
87
|
+
const pairs = cookieHeader.split(";");
|
|
88
|
+
for (const pair of pairs) {
|
|
89
|
+
const [key, ...valueParts] = pair.split("=");
|
|
90
|
+
const trimmedKey = key?.trim();
|
|
91
|
+
const value = valueParts.join("=").trim();
|
|
92
|
+
if (trimmedKey) cookies[trimmedKey] = decodeURIComponent(value);
|
|
93
|
+
}
|
|
94
|
+
return cookies;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Build a Set-Cookie header string with security attributes
|
|
98
|
+
*/
|
|
99
|
+
function buildSetCookieHeader(value, options = {}) {
|
|
100
|
+
const { httpOnly = true, secure = true, sameSite = "Strict", maxAge = MAX_AGE, path = "/" } = options;
|
|
101
|
+
const effectiveSecure = sameSite === "None" ? true : secure;
|
|
102
|
+
const parts = [
|
|
103
|
+
`${COOKIE_NAME}=${encodeURIComponent(value)}`,
|
|
104
|
+
`Max-Age=${maxAge}`,
|
|
105
|
+
`Path=${path}`
|
|
106
|
+
];
|
|
107
|
+
if (httpOnly) parts.push("HttpOnly");
|
|
108
|
+
if (effectiveSecure) parts.push("Secure");
|
|
109
|
+
if (sameSite) parts.push(`SameSite=${sameSite}`);
|
|
110
|
+
return parts.join("; ");
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Build a Set-Cookie header to clear the session cookie
|
|
114
|
+
*/
|
|
115
|
+
function buildClearCookieHeader(options = {}) {
|
|
116
|
+
return buildSetCookieHeader("", {
|
|
117
|
+
...options,
|
|
118
|
+
maxAge: 0
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Extract session ID from headers, checking cookies first, then query/body
|
|
123
|
+
*/
|
|
124
|
+
function extractSessionId(headers, queryParam, bodySessionId) {
|
|
125
|
+
const cookieHeader = headers.get("Cookie");
|
|
126
|
+
const cookies = parseCookies(cookieHeader);
|
|
127
|
+
const sessionIdFromCookie = cookies[COOKIE_NAME];
|
|
128
|
+
if (sessionIdFromCookie) return sessionIdFromCookie;
|
|
129
|
+
if (queryParam) return queryParam;
|
|
130
|
+
if (bodySessionId) return bodySessionId;
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/user/user-actions.ts
|
|
136
|
+
function createUserServices() {
|
|
137
|
+
return {
|
|
138
|
+
createUser: function(email, passwordHash, role = "user") {
|
|
139
|
+
return this.serviceTx(authSchema).mutate(({ uow }) => {
|
|
140
|
+
const id = uow.create("user", {
|
|
141
|
+
email,
|
|
142
|
+
passwordHash,
|
|
143
|
+
role
|
|
144
|
+
});
|
|
145
|
+
return {
|
|
146
|
+
id: id.valueOf(),
|
|
147
|
+
email,
|
|
148
|
+
role
|
|
149
|
+
};
|
|
150
|
+
}).build();
|
|
151
|
+
},
|
|
152
|
+
getUserByEmail: function(email) {
|
|
153
|
+
return this.serviceTx(authSchema).retrieve((uow) => uow.findFirst("user", (b) => b.whereIndex("idx_user_email", (eb) => eb("email", "=", email)))).transformRetrieve(([user]) => user ? {
|
|
154
|
+
id: user.id.valueOf(),
|
|
155
|
+
email: user.email,
|
|
156
|
+
passwordHash: user.passwordHash,
|
|
157
|
+
role: user.role
|
|
158
|
+
} : null).build();
|
|
159
|
+
},
|
|
160
|
+
updateUserRole: function(userId, role) {
|
|
161
|
+
return this.serviceTx(authSchema).mutate(({ uow }) => {
|
|
162
|
+
uow.update("user", userId, (b) => b.set({ role }));
|
|
163
|
+
return { success: true };
|
|
164
|
+
}).build();
|
|
165
|
+
},
|
|
166
|
+
updateUserPassword: function(userId, passwordHash) {
|
|
167
|
+
return this.serviceTx(authSchema).mutate(({ uow }) => {
|
|
168
|
+
uow.update("user", userId, (b) => b.set({ passwordHash }));
|
|
169
|
+
return { success: true };
|
|
170
|
+
}).build();
|
|
171
|
+
},
|
|
172
|
+
signUpWithSession: function(email, passwordHash) {
|
|
173
|
+
const expiresAt = new Date();
|
|
174
|
+
expiresAt.setDate(expiresAt.getDate() + 30);
|
|
175
|
+
return this.serviceTx(authSchema).retrieve((uow) => uow.findFirst("user", (b) => b.whereIndex("idx_user_email", (eb) => eb("email", "=", email)))).mutate(({ uow, retrieveResult: [existingUser] }) => {
|
|
176
|
+
if (existingUser) return {
|
|
177
|
+
ok: false,
|
|
178
|
+
code: "email_already_exists"
|
|
179
|
+
};
|
|
180
|
+
const userId = uow.create("user", {
|
|
181
|
+
email,
|
|
182
|
+
passwordHash,
|
|
183
|
+
role: "user"
|
|
184
|
+
});
|
|
185
|
+
const sessionId = uow.create("session", {
|
|
186
|
+
userId,
|
|
187
|
+
expiresAt
|
|
188
|
+
});
|
|
189
|
+
return {
|
|
190
|
+
ok: true,
|
|
191
|
+
sessionId: sessionId.valueOf(),
|
|
192
|
+
userId: userId.valueOf(),
|
|
193
|
+
email,
|
|
194
|
+
role: "user"
|
|
195
|
+
};
|
|
196
|
+
}).build();
|
|
197
|
+
},
|
|
198
|
+
updateUserRoleWithSession: function(sessionId, userId, role) {
|
|
199
|
+
const now = new Date();
|
|
200
|
+
return this.serviceTx(authSchema).retrieve((uow) => uow.findFirst("session", (b) => b.whereIndex("primary", (eb) => eb("id", "=", sessionId)).join((j) => j.sessionOwner((b$1) => b$1.select([
|
|
201
|
+
"id",
|
|
202
|
+
"email",
|
|
203
|
+
"role"
|
|
204
|
+
]))))).mutate(({ uow, retrieveResult: [session] }) => {
|
|
205
|
+
if (!session || !session.sessionOwner) return {
|
|
206
|
+
ok: false,
|
|
207
|
+
code: "session_invalid"
|
|
208
|
+
};
|
|
209
|
+
if (session.expiresAt < now) {
|
|
210
|
+
uow.delete("session", session.id, (b) => b.check());
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
code: "session_invalid"
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
if (session.sessionOwner.role !== "admin") return {
|
|
217
|
+
ok: false,
|
|
218
|
+
code: "permission_denied"
|
|
219
|
+
};
|
|
220
|
+
uow.update("user", userId, (b) => b.set({ role }));
|
|
221
|
+
return { ok: true };
|
|
222
|
+
}).build();
|
|
223
|
+
},
|
|
224
|
+
changePasswordWithSession: function(sessionId, passwordHash) {
|
|
225
|
+
const now = new Date();
|
|
226
|
+
return this.serviceTx(authSchema).retrieve((uow) => uow.findFirst("session", (b) => b.whereIndex("primary", (eb) => eb("id", "=", sessionId)).join((j) => j.sessionOwner((b$1) => b$1.select([
|
|
227
|
+
"id",
|
|
228
|
+
"email",
|
|
229
|
+
"role"
|
|
230
|
+
]))))).mutate(({ uow, retrieveResult: [session] }) => {
|
|
231
|
+
if (!session || !session.sessionOwner) return {
|
|
232
|
+
ok: false,
|
|
233
|
+
code: "session_invalid"
|
|
234
|
+
};
|
|
235
|
+
if (session.expiresAt < now) {
|
|
236
|
+
uow.delete("session", session.id, (b) => b.check());
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
code: "session_invalid"
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
uow.update("user", session.sessionOwner.id, (b) => b.set({ passwordHash }).check());
|
|
243
|
+
return { ok: true };
|
|
244
|
+
}).build();
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
const userActionsRoutesFactory = defineRoutes().create(({ services, config }) => {
|
|
249
|
+
return [
|
|
250
|
+
defineRoute({
|
|
251
|
+
method: "PATCH",
|
|
252
|
+
path: "/users/:userId/role",
|
|
253
|
+
inputSchema: z.object({ role: z.enum(["user", "admin"]) }),
|
|
254
|
+
outputSchema: z.object({ success: z.boolean() }),
|
|
255
|
+
errorCodes: [
|
|
256
|
+
"invalid_input",
|
|
257
|
+
"session_invalid",
|
|
258
|
+
"permission_denied"
|
|
259
|
+
],
|
|
260
|
+
handler: async function({ input, pathParams, headers, query }, { json, error }) {
|
|
261
|
+
const { role } = await input.valid();
|
|
262
|
+
const { userId } = pathParams;
|
|
263
|
+
const sessionId = extractSessionId(headers, query.get("sessionId"));
|
|
264
|
+
if (!sessionId) return error({
|
|
265
|
+
message: "Session ID required",
|
|
266
|
+
code: "session_invalid"
|
|
267
|
+
}, 400);
|
|
268
|
+
const [result] = await this.handlerTx().withServiceCalls(() => [services.updateUserRoleWithSession(sessionId, userId, role)]).execute();
|
|
269
|
+
if (!result.ok) {
|
|
270
|
+
if (result.code === "permission_denied") return error({
|
|
271
|
+
message: "Unauthorized",
|
|
272
|
+
code: "permission_denied"
|
|
273
|
+
}, 401);
|
|
274
|
+
return error({
|
|
275
|
+
message: "Invalid session",
|
|
276
|
+
code: "session_invalid"
|
|
277
|
+
}, 401);
|
|
278
|
+
}
|
|
279
|
+
return json({ success: true });
|
|
280
|
+
}
|
|
281
|
+
}),
|
|
282
|
+
defineRoute({
|
|
283
|
+
method: "POST",
|
|
284
|
+
path: "/sign-up",
|
|
285
|
+
inputSchema: z.object({
|
|
286
|
+
email: z.email(),
|
|
287
|
+
password: z.string().min(8).max(100)
|
|
288
|
+
}),
|
|
289
|
+
outputSchema: z.object({
|
|
290
|
+
sessionId: z.string(),
|
|
291
|
+
userId: z.string(),
|
|
292
|
+
email: z.string(),
|
|
293
|
+
role: z.enum(["user", "admin"])
|
|
294
|
+
}),
|
|
295
|
+
errorCodes: ["email_already_exists", "invalid_input"],
|
|
296
|
+
handler: async function({ input }, { json, error }) {
|
|
297
|
+
const { email, password } = await input.valid();
|
|
298
|
+
const passwordHash = await hashPassword(password);
|
|
299
|
+
const [result] = await this.handlerTx().withServiceCalls(() => [services.signUpWithSession(email, passwordHash)]).execute();
|
|
300
|
+
if (!result.ok) return error({
|
|
301
|
+
message: "Email already exists",
|
|
302
|
+
code: "email_already_exists"
|
|
303
|
+
}, 400);
|
|
304
|
+
const setCookieHeader = buildSetCookieHeader(result.sessionId, config.cookieOptions);
|
|
305
|
+
return json({
|
|
306
|
+
sessionId: result.sessionId,
|
|
307
|
+
userId: result.userId,
|
|
308
|
+
email: result.email,
|
|
309
|
+
role: result.role
|
|
310
|
+
}, { headers: { "Set-Cookie": setCookieHeader } });
|
|
311
|
+
}
|
|
312
|
+
}),
|
|
313
|
+
defineRoute({
|
|
314
|
+
method: "POST",
|
|
315
|
+
path: "/sign-in",
|
|
316
|
+
inputSchema: z.object({
|
|
317
|
+
email: z.email(),
|
|
318
|
+
password: z.string().min(8).max(100)
|
|
319
|
+
}),
|
|
320
|
+
outputSchema: z.object({
|
|
321
|
+
sessionId: z.string(),
|
|
322
|
+
userId: z.string(),
|
|
323
|
+
email: z.string(),
|
|
324
|
+
role: z.enum(["user", "admin"])
|
|
325
|
+
}),
|
|
326
|
+
errorCodes: ["invalid_credentials"],
|
|
327
|
+
handler: async function({ input }, { json, error }) {
|
|
328
|
+
const { email, password } = await input.valid();
|
|
329
|
+
const [user] = await this.handlerTx().withServiceCalls(() => [services.getUserByEmail(email)]).execute();
|
|
330
|
+
if (!user) return error({
|
|
331
|
+
message: "Invalid credentials",
|
|
332
|
+
code: "invalid_credentials"
|
|
333
|
+
}, 401);
|
|
334
|
+
const isValid = await verifyPassword(password, user.passwordHash);
|
|
335
|
+
if (!isValid) return error({
|
|
336
|
+
message: "Invalid credentials",
|
|
337
|
+
code: "invalid_credentials"
|
|
338
|
+
}, 401);
|
|
339
|
+
const [session] = await this.handlerTx().withServiceCalls(() => [services.createSession(user.id)]).execute();
|
|
340
|
+
const setCookieHeader = buildSetCookieHeader(session.id, config.cookieOptions);
|
|
341
|
+
return json({
|
|
342
|
+
sessionId: session.id,
|
|
343
|
+
userId: user.id,
|
|
344
|
+
email: user.email,
|
|
345
|
+
role: user.role
|
|
346
|
+
}, { headers: { "Set-Cookie": setCookieHeader } });
|
|
347
|
+
}
|
|
348
|
+
}),
|
|
349
|
+
defineRoute({
|
|
350
|
+
method: "POST",
|
|
351
|
+
path: "/change-password",
|
|
352
|
+
inputSchema: z.object({ newPassword: z.string().min(8).max(100) }),
|
|
353
|
+
outputSchema: z.object({ success: z.boolean() }),
|
|
354
|
+
errorCodes: ["session_invalid"],
|
|
355
|
+
handler: async function({ input, headers, query }, { json, error }) {
|
|
356
|
+
const { newPassword } = await input.valid();
|
|
357
|
+
const sessionId = extractSessionId(headers, query.get("sessionId"));
|
|
358
|
+
if (!sessionId) return error({
|
|
359
|
+
message: "Session ID required",
|
|
360
|
+
code: "session_invalid"
|
|
361
|
+
}, 400);
|
|
362
|
+
const passwordHash = await hashPassword(newPassword);
|
|
363
|
+
const [result] = await this.handlerTx().withServiceCalls(() => [services.changePasswordWithSession(sessionId, passwordHash)]).execute();
|
|
364
|
+
if (!result.ok) return error({
|
|
365
|
+
message: "Invalid session",
|
|
366
|
+
code: "session_invalid"
|
|
367
|
+
}, 401);
|
|
368
|
+
return json({ success: true });
|
|
369
|
+
}
|
|
370
|
+
})
|
|
371
|
+
];
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region src/session/session.ts
|
|
376
|
+
function createSessionServices(cookieOptions) {
|
|
377
|
+
const services = {
|
|
378
|
+
buildSessionCookie: function(sessionId) {
|
|
379
|
+
return buildSetCookieHeader(sessionId, cookieOptions);
|
|
380
|
+
},
|
|
381
|
+
createSession: function(userId) {
|
|
382
|
+
const expiresAt = new Date();
|
|
383
|
+
expiresAt.setDate(expiresAt.getDate() + 30);
|
|
384
|
+
return this.serviceTx(authSchema).mutate(({ uow }) => {
|
|
385
|
+
const id = uow.create("session", {
|
|
386
|
+
userId,
|
|
387
|
+
expiresAt
|
|
388
|
+
});
|
|
389
|
+
return {
|
|
390
|
+
id: id.valueOf(),
|
|
391
|
+
userId,
|
|
392
|
+
expiresAt
|
|
393
|
+
};
|
|
394
|
+
}).build();
|
|
395
|
+
},
|
|
396
|
+
validateSession: function(sessionId) {
|
|
397
|
+
const now = new Date();
|
|
398
|
+
return this.serviceTx(authSchema).retrieve((uow) => uow.findFirst("session", (b) => b.whereIndex("primary", (eb) => eb("id", "=", sessionId)).join((j) => j.sessionOwner((b$1) => b$1.select([
|
|
399
|
+
"id",
|
|
400
|
+
"email",
|
|
401
|
+
"role"
|
|
402
|
+
]))))).mutate(({ uow, retrieveResult: [session] }) => {
|
|
403
|
+
if (!session) return null;
|
|
404
|
+
if (session.expiresAt < now) {
|
|
405
|
+
uow.delete("session", session.id, (b) => b.check());
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
if (!session.sessionOwner) return null;
|
|
409
|
+
return {
|
|
410
|
+
id: session.id.valueOf(),
|
|
411
|
+
userId: session.userId,
|
|
412
|
+
user: {
|
|
413
|
+
id: session.sessionOwner.id.valueOf(),
|
|
414
|
+
email: session.sessionOwner.email,
|
|
415
|
+
role: session.sessionOwner.role
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
}).build();
|
|
419
|
+
},
|
|
420
|
+
invalidateSession: function(sessionId) {
|
|
421
|
+
return this.serviceTx(authSchema).retrieve((uow) => uow.findFirst("session", (b) => b.whereIndex("primary", (eb) => eb("id", "=", sessionId)))).mutate(({ uow, retrieveResult: [session] }) => {
|
|
422
|
+
if (!session) return false;
|
|
423
|
+
uow.delete("session", session.id, (b) => b.check());
|
|
424
|
+
return true;
|
|
425
|
+
}).build();
|
|
426
|
+
},
|
|
427
|
+
getSession: function(headers) {
|
|
428
|
+
const sessionId = extractSessionId(headers);
|
|
429
|
+
const now = new Date();
|
|
430
|
+
return this.serviceTx(authSchema).retrieve((uow) => uow.findFirst("session", (b) => b.whereIndex("primary", (eb) => eb("id", "=", sessionId ?? "")).join((j) => j.sessionOwner((b$1) => b$1.select([
|
|
431
|
+
"id",
|
|
432
|
+
"email",
|
|
433
|
+
"role"
|
|
434
|
+
]))))).mutate(({ uow, retrieveResult: [session] }) => {
|
|
435
|
+
if (!session || !sessionId) return void 0;
|
|
436
|
+
if (session.expiresAt < now) {
|
|
437
|
+
uow.delete("session", session.id, (b) => b.check());
|
|
438
|
+
return void 0;
|
|
439
|
+
}
|
|
440
|
+
if (!session.sessionOwner) return void 0;
|
|
441
|
+
return {
|
|
442
|
+
userId: session.sessionOwner.id.valueOf(),
|
|
443
|
+
email: session.sessionOwner.email
|
|
444
|
+
};
|
|
445
|
+
}).build();
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
return services;
|
|
449
|
+
}
|
|
450
|
+
const sessionRoutesFactory = defineRoutes().create(({ services, config }) => {
|
|
451
|
+
return [defineRoute({
|
|
452
|
+
method: "POST",
|
|
453
|
+
path: "/sign-out",
|
|
454
|
+
inputSchema: z.object({ sessionId: z.string().optional() }).optional(),
|
|
455
|
+
outputSchema: z.object({ success: z.boolean() }),
|
|
456
|
+
errorCodes: ["session_not_found"],
|
|
457
|
+
handler: async function({ input, headers }, { json, error }) {
|
|
458
|
+
const body = await input.valid();
|
|
459
|
+
const sessionId = extractSessionId(headers, null, body?.sessionId);
|
|
460
|
+
if (!sessionId) return error({
|
|
461
|
+
message: "Session ID required",
|
|
462
|
+
code: "session_not_found"
|
|
463
|
+
}, 400);
|
|
464
|
+
const [success] = await this.handlerTx().withServiceCalls(() => [services.invalidateSession(sessionId)]).execute();
|
|
465
|
+
const clearCookieHeader = buildClearCookieHeader(config.cookieOptions ?? {});
|
|
466
|
+
if (!success) return json({ success: false }, { headers: { "Set-Cookie": clearCookieHeader } });
|
|
467
|
+
return json({ success: true }, { headers: { "Set-Cookie": clearCookieHeader } });
|
|
468
|
+
}
|
|
469
|
+
}), defineRoute({
|
|
470
|
+
method: "GET",
|
|
471
|
+
path: "/me",
|
|
472
|
+
queryParameters: ["sessionId"],
|
|
473
|
+
outputSchema: z.object({
|
|
474
|
+
userId: z.string(),
|
|
475
|
+
email: z.string(),
|
|
476
|
+
role: z.enum(["user", "admin"])
|
|
477
|
+
}),
|
|
478
|
+
errorCodes: ["session_invalid"],
|
|
479
|
+
handler: async function({ query, headers }, { json, error }) {
|
|
480
|
+
const sessionId = extractSessionId(headers, query.get("sessionId"));
|
|
481
|
+
if (!sessionId) return error({
|
|
482
|
+
message: "Session ID required",
|
|
483
|
+
code: "session_invalid"
|
|
484
|
+
}, 400);
|
|
485
|
+
const [session] = await this.handlerTx().withServiceCalls(() => [services.validateSession(sessionId)]).execute();
|
|
486
|
+
if (!session) return error({
|
|
487
|
+
message: "Invalid session",
|
|
488
|
+
code: "session_invalid"
|
|
489
|
+
}, 401);
|
|
490
|
+
return json({
|
|
491
|
+
userId: session.user.id,
|
|
492
|
+
email: session.user.email,
|
|
493
|
+
role: session.user.role
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
})];
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
//#endregion
|
|
500
|
+
//#region src/user/user-overview.ts
|
|
501
|
+
function createUserOverviewServices() {
|
|
502
|
+
const mapUser = (user) => ({
|
|
503
|
+
id: String(user.id),
|
|
504
|
+
email: user.email,
|
|
505
|
+
role: user.role,
|
|
506
|
+
createdAt: user.createdAt
|
|
507
|
+
});
|
|
508
|
+
return { getUsersWithCursor: function(params) {
|
|
509
|
+
const { search, sortBy, sortOrder, pageSize, cursor } = params;
|
|
510
|
+
const effectiveSortBy = search ? "email" : sortBy;
|
|
511
|
+
const indexName = effectiveSortBy === "email" ? "idx_user_email" : "idx_user_createdAt";
|
|
512
|
+
const effectiveSortOrder = cursor ? cursor.orderDirection : sortOrder;
|
|
513
|
+
const effectivePageSize = cursor ? cursor.pageSize : pageSize;
|
|
514
|
+
return this.serviceTx(authSchema).retrieve((uow) => uow.findWithCursor("user", (b) => {
|
|
515
|
+
if (search) {
|
|
516
|
+
const query$1 = b.whereIndex("idx_user_email", (eb) => eb("email", "contains", search)).orderByIndex("idx_user_email", effectiveSortOrder).pageSize(effectivePageSize);
|
|
517
|
+
return cursor ? query$1.after(cursor) : query$1;
|
|
518
|
+
}
|
|
519
|
+
const query = b.whereIndex(indexName).orderByIndex(indexName, effectiveSortOrder).pageSize(effectivePageSize);
|
|
520
|
+
return cursor ? query.after(cursor) : query;
|
|
521
|
+
})).transformRetrieve(([result]) => ({
|
|
522
|
+
users: result.items.map(mapUser),
|
|
523
|
+
cursor: result.cursor,
|
|
524
|
+
hasNextPage: result.hasNextPage
|
|
525
|
+
})).build();
|
|
526
|
+
} };
|
|
527
|
+
}
|
|
528
|
+
const sortBySchema = z.enum(["email", "createdAt"]);
|
|
529
|
+
const sortOrderSchema = z.enum(["asc", "desc"]);
|
|
530
|
+
const userOverviewRoutesFactory = defineRoutes().create(({ services }) => {
|
|
531
|
+
const querySchema = z.object({
|
|
532
|
+
search: z.string().optional(),
|
|
533
|
+
sortBy: sortBySchema.default("createdAt"),
|
|
534
|
+
sortOrder: sortOrderSchema.default("desc"),
|
|
535
|
+
pageSize: z.coerce.number().int().min(1).max(100).default(20)
|
|
536
|
+
});
|
|
537
|
+
const parseCursor = (cursorParam) => {
|
|
538
|
+
if (!cursorParam) return void 0;
|
|
539
|
+
try {
|
|
540
|
+
return decodeCursor(cursorParam);
|
|
541
|
+
} catch {
|
|
542
|
+
return void 0;
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
return [defineRoute({
|
|
546
|
+
method: "GET",
|
|
547
|
+
path: "/users",
|
|
548
|
+
queryParameters: [
|
|
549
|
+
"search",
|
|
550
|
+
"sortBy",
|
|
551
|
+
"sortOrder",
|
|
552
|
+
"pageSize",
|
|
553
|
+
"cursor"
|
|
554
|
+
],
|
|
555
|
+
outputSchema: z.object({
|
|
556
|
+
users: z.array(z.object({
|
|
557
|
+
id: z.string(),
|
|
558
|
+
email: z.string(),
|
|
559
|
+
role: z.enum(["user", "admin"]),
|
|
560
|
+
createdAt: z.string()
|
|
561
|
+
})),
|
|
562
|
+
cursor: z.string().optional(),
|
|
563
|
+
hasNextPage: z.boolean(),
|
|
564
|
+
sortBy: sortBySchema
|
|
565
|
+
}),
|
|
566
|
+
errorCodes: ["invalid_input"],
|
|
567
|
+
handler: async function({ query }, { json, error }) {
|
|
568
|
+
const parsed = querySchema.safeParse(Object.fromEntries(query.entries()));
|
|
569
|
+
if (!parsed.success) return error({
|
|
570
|
+
message: "Invalid query parameters",
|
|
571
|
+
code: "invalid_input"
|
|
572
|
+
}, 400);
|
|
573
|
+
const rawSearch = parsed.data.search?.trim();
|
|
574
|
+
const search = rawSearch ? rawSearch : void 0;
|
|
575
|
+
const sortBy = search ? "email" : parsed.data.sortBy;
|
|
576
|
+
const params = {
|
|
577
|
+
search,
|
|
578
|
+
sortBy,
|
|
579
|
+
sortOrder: parsed.data.sortOrder,
|
|
580
|
+
pageSize: parsed.data.pageSize
|
|
581
|
+
};
|
|
582
|
+
const cursor = parseCursor(query.get("cursor"));
|
|
583
|
+
const [result] = await this.handlerTx().withServiceCalls(() => [services.getUsersWithCursor({
|
|
584
|
+
...params,
|
|
585
|
+
cursor
|
|
586
|
+
})]).execute();
|
|
587
|
+
return json({
|
|
588
|
+
users: result.users.map((user) => ({
|
|
589
|
+
id: user.id,
|
|
590
|
+
email: user.email,
|
|
591
|
+
role: user.role,
|
|
592
|
+
createdAt: user.createdAt.toISOString()
|
|
593
|
+
})),
|
|
594
|
+
cursor: result.cursor?.encode(),
|
|
595
|
+
hasNextPage: result.hasNextPage,
|
|
596
|
+
sortBy: params.sortBy
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
})];
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
//#endregion
|
|
603
|
+
//#region src/index.ts
|
|
604
|
+
const authFragmentDefinition = defineFragment("auth").extend(withDatabase(authSchema)).providesBaseService(({ defineService, config }) => {
|
|
605
|
+
return defineService({
|
|
606
|
+
...createUserServices(),
|
|
607
|
+
...createSessionServices(config.cookieOptions),
|
|
608
|
+
...createUserOverviewServices()
|
|
609
|
+
});
|
|
610
|
+
}).build();
|
|
611
|
+
function createAuthFragment(config = {}, fragnoConfig) {
|
|
612
|
+
const options = {
|
|
613
|
+
...fragnoConfig,
|
|
614
|
+
databaseNamespace: fragnoConfig.databaseNamespace !== void 0 ? fragnoConfig.databaseNamespace : "simple-auth-db"
|
|
615
|
+
};
|
|
616
|
+
return instantiate(authFragmentDefinition).withConfig(config).withOptions(options).withRoutes([
|
|
617
|
+
userActionsRoutesFactory,
|
|
618
|
+
sessionRoutesFactory,
|
|
619
|
+
userOverviewRoutesFactory
|
|
620
|
+
]).build();
|
|
621
|
+
}
|
|
622
|
+
function createAuthFragmentClients(fragnoConfig) {
|
|
623
|
+
const config = { ...fragnoConfig };
|
|
624
|
+
const b = createClientBuilder(authFragmentDefinition, config, [
|
|
625
|
+
userActionsRoutesFactory,
|
|
626
|
+
sessionRoutesFactory,
|
|
627
|
+
userOverviewRoutesFactory
|
|
628
|
+
], {
|
|
629
|
+
type: "options",
|
|
630
|
+
options: { credentials: "include" }
|
|
631
|
+
});
|
|
632
|
+
const useMe = b.createHook("/me");
|
|
633
|
+
const useSignUp = b.createMutator("POST", "/sign-up");
|
|
634
|
+
const useSignIn = b.createMutator("POST", "/sign-in");
|
|
635
|
+
const useSignOut = b.createMutator("POST", "/sign-out", (invalidate) => {
|
|
636
|
+
invalidate("GET", "/me", {});
|
|
637
|
+
invalidate("GET", "/users", {});
|
|
638
|
+
});
|
|
639
|
+
const useUsers = b.createHook("/users");
|
|
640
|
+
const useUpdateUserRole = b.createMutator("PATCH", "/users/:userId/role", (invalidate) => {
|
|
641
|
+
invalidate("GET", "/users", {});
|
|
642
|
+
invalidate("GET", "/me", {});
|
|
643
|
+
});
|
|
644
|
+
const useChangePassword = b.createMutator("POST", "/change-password");
|
|
645
|
+
return {
|
|
646
|
+
useSignUp,
|
|
647
|
+
useSignIn,
|
|
648
|
+
useSignOut,
|
|
649
|
+
useMe,
|
|
650
|
+
useUsers,
|
|
651
|
+
useUpdateUserRole,
|
|
652
|
+
useChangePassword,
|
|
653
|
+
signIn: { email: async ({ email, password, rememberMe: _rememberMe }) => {
|
|
654
|
+
return useSignIn.mutateQuery({ body: {
|
|
655
|
+
email,
|
|
656
|
+
password
|
|
657
|
+
} });
|
|
658
|
+
} },
|
|
659
|
+
signUp: { email: async ({ email, password }) => {
|
|
660
|
+
return useSignUp.mutateQuery({ body: {
|
|
661
|
+
email,
|
|
662
|
+
password
|
|
663
|
+
} });
|
|
664
|
+
} },
|
|
665
|
+
signOut: (params) => {
|
|
666
|
+
return useSignOut.mutateQuery({ body: params?.sessionId ? { sessionId: params.sessionId } : {} });
|
|
667
|
+
},
|
|
668
|
+
me: async (params) => {
|
|
669
|
+
if (params?.sessionId) return useMe.query({ query: { sessionId: params.sessionId } });
|
|
670
|
+
return useMe.query();
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
//#endregion
|
|
676
|
+
export { authFragmentDefinition, createAuthFragment, createAuthFragmentClients };
|
|
677
|
+
//# sourceMappingURL=index.js.map
|