@brika/auth 0.1.1
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 +207 -0
- package/package.json +50 -0
- package/src/__tests__/AuthClient.test.ts +736 -0
- package/src/__tests__/AuthService.test.ts +140 -0
- package/src/__tests__/ScopeService.test.ts +156 -0
- package/src/__tests__/SessionService.test.ts +311 -0
- package/src/__tests__/UserService-avatar.test.ts +277 -0
- package/src/__tests__/UserService.test.ts +223 -0
- package/src/__tests__/canAccess.test.ts +166 -0
- package/src/__tests__/disabledScopes.test.ts +101 -0
- package/src/__tests__/middleware.test.ts +190 -0
- package/src/__tests__/plugin.test.ts +78 -0
- package/src/__tests__/requireSession.test.ts +78 -0
- package/src/__tests__/routes-auth.test.ts +248 -0
- package/src/__tests__/routes-profile.test.ts +403 -0
- package/src/__tests__/routes-scopes.test.ts +64 -0
- package/src/__tests__/routes-sessions.test.ts +235 -0
- package/src/__tests__/routes-users.test.ts +477 -0
- package/src/__tests__/serveImage.test.ts +277 -0
- package/src/__tests__/setup.test.ts +270 -0
- package/src/__tests__/verifyToken.test.ts +219 -0
- package/src/client/AuthClient.ts +312 -0
- package/src/client/http-client.ts +84 -0
- package/src/client/index.ts +19 -0
- package/src/config.ts +82 -0
- package/src/constants.ts +10 -0
- package/src/index.ts +16 -0
- package/src/lib/define-roles.ts +35 -0
- package/src/lib/define-scopes.ts +48 -0
- package/src/middleware/canAccess.ts +126 -0
- package/src/middleware/index.ts +13 -0
- package/src/middleware/requireAuth.ts +35 -0
- package/src/middleware/requireScope.ts +46 -0
- package/src/middleware/verifyToken.ts +52 -0
- package/src/plugin.ts +86 -0
- package/src/react/AuthProvider.tsx +105 -0
- package/src/react/hooks.ts +128 -0
- package/src/react/index.ts +51 -0
- package/src/react/withScopeGuard.tsx +73 -0
- package/src/roles.ts +40 -0
- package/src/schemas.ts +112 -0
- package/src/scopes.ts +60 -0
- package/src/server/index.ts +44 -0
- package/src/server/requireSession.ts +44 -0
- package/src/server/routes/auth.ts +102 -0
- package/src/server/routes/cookie.ts +7 -0
- package/src/server/routes/index.ts +32 -0
- package/src/server/routes/profile.ts +162 -0
- package/src/server/routes/scopes.ts +22 -0
- package/src/server/routes/sessions.ts +68 -0
- package/src/server/routes/setup.ts +50 -0
- package/src/server/routes/users.ts +175 -0
- package/src/server/serveImage.ts +91 -0
- package/src/services/AuthService.ts +80 -0
- package/src/services/ScopeService.ts +94 -0
- package/src/services/SessionService.ts +245 -0
- package/src/services/UserService.ts +245 -0
- package/src/setup.ts +99 -0
- package/src/tanstack/index.ts +15 -0
- package/src/tanstack/routeBuilder.ts +311 -0
- package/src/types.ts +118 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Profile routes — update name, avatar upload/remove/serve
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { inject } from '@brika/di';
|
|
6
|
+
import { BadRequest, rateLimit, route } from '@brika/router';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { NameSchema, PasswordSchema } from '../../schemas';
|
|
9
|
+
import { SessionService } from '../../services/SessionService';
|
|
10
|
+
import { UserService } from '../../services/UserService';
|
|
11
|
+
import { requireSession } from '../requireSession';
|
|
12
|
+
import { ImageQuerySchema, serveImage } from '../serveImage';
|
|
13
|
+
|
|
14
|
+
const IMAGE_MAGIC_BYTES: Array<{
|
|
15
|
+
mime: string;
|
|
16
|
+
bytes: number[];
|
|
17
|
+
}> = [
|
|
18
|
+
{
|
|
19
|
+
mime: 'image/png',
|
|
20
|
+
bytes: [0x89, 0x50, 0x4e, 0x47],
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
mime: 'image/jpeg',
|
|
24
|
+
bytes: [0xff, 0xd8, 0xff],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
mime: 'image/webp',
|
|
28
|
+
bytes: [0x52, 0x49, 0x46, 0x46],
|
|
29
|
+
}, // "RIFF"
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function isValidImage(buffer: Buffer): boolean {
|
|
33
|
+
return IMAGE_MAGIC_BYTES.some(({ bytes }) => bytes.every((b, i) => buffer[i] === b));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const ChangePasswordSchema = z.object({
|
|
37
|
+
currentPassword: z.string().min(1),
|
|
38
|
+
newPassword: PasswordSchema,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const UpdateProfileSchema = z.object({
|
|
42
|
+
name: NameSchema.optional(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/** PUT /profile — Update own profile (name) */
|
|
46
|
+
const updateProfile = route.put({
|
|
47
|
+
path: '/profile',
|
|
48
|
+
body: UpdateProfileSchema,
|
|
49
|
+
handler: (ctx) => {
|
|
50
|
+
const session = requireSession(ctx);
|
|
51
|
+
const userService = inject(UserService);
|
|
52
|
+
const user = userService.updateUser(session.userId, {
|
|
53
|
+
name: ctx.body.name,
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
user,
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** PUT /profile/avatar — Upload avatar image */
|
|
62
|
+
const uploadAvatar = route.put({
|
|
63
|
+
path: '/profile/avatar',
|
|
64
|
+
handler: async (ctx) => {
|
|
65
|
+
const session = requireSession(ctx);
|
|
66
|
+
const contentType = ctx.req.headers.get('content-type') ?? '';
|
|
67
|
+
let imageBuffer: Buffer;
|
|
68
|
+
|
|
69
|
+
if (contentType.includes('application/json')) {
|
|
70
|
+
const body = (ctx.body ?? {}) as {
|
|
71
|
+
data: string;
|
|
72
|
+
};
|
|
73
|
+
if (!body.data) {
|
|
74
|
+
throw new BadRequest('Missing image data');
|
|
75
|
+
}
|
|
76
|
+
imageBuffer = Buffer.from(body.data, 'base64');
|
|
77
|
+
} else {
|
|
78
|
+
imageBuffer = Buffer.from(await ctx.req.arrayBuffer());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (imageBuffer.length === 0) {
|
|
82
|
+
throw new BadRequest('Empty image data');
|
|
83
|
+
}
|
|
84
|
+
if (imageBuffer.length > 5 * 1024 * 1024) {
|
|
85
|
+
throw new BadRequest('Image too large (max 5MB)');
|
|
86
|
+
}
|
|
87
|
+
if (!isValidImage(imageBuffer)) {
|
|
88
|
+
throw new BadRequest('Invalid image format (PNG, JPEG, or WebP required)');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const userService = inject(UserService);
|
|
92
|
+
const avatarHash = userService.setAvatar(session.userId, imageBuffer);
|
|
93
|
+
return {
|
|
94
|
+
ok: true,
|
|
95
|
+
avatarHash,
|
|
96
|
+
};
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/** DELETE /profile/avatar — Remove avatar */
|
|
101
|
+
const removeAvatar = route.delete({
|
|
102
|
+
path: '/profile/avatar',
|
|
103
|
+
handler: (ctx) => {
|
|
104
|
+
const session = requireSession(ctx);
|
|
105
|
+
const userService = inject(UserService);
|
|
106
|
+
userService.removeAvatar(session.userId);
|
|
107
|
+
return {
|
|
108
|
+
ok: true,
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/** PUT /profile/password — Change own password */
|
|
114
|
+
const changePassword = route.put({
|
|
115
|
+
path: '/profile/password',
|
|
116
|
+
middleware: [
|
|
117
|
+
rateLimit({
|
|
118
|
+
window: 900,
|
|
119
|
+
max: 10,
|
|
120
|
+
}),
|
|
121
|
+
],
|
|
122
|
+
body: ChangePasswordSchema,
|
|
123
|
+
handler: async (ctx) => {
|
|
124
|
+
const session = requireSession(ctx);
|
|
125
|
+
|
|
126
|
+
const userService = inject(UserService);
|
|
127
|
+
const valid = await userService.verifyPassword(session.userId, ctx.body.currentPassword);
|
|
128
|
+
if (!valid) {
|
|
129
|
+
throw new BadRequest('Invalid current password');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
await userService.setPassword(session.userId, ctx.body.newPassword);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
throw new BadRequest(err instanceof Error ? err.message : 'Invalid password');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Revoke all other sessions after password change to invalidate potentially compromised sessions
|
|
139
|
+
const sessionService = inject(SessionService);
|
|
140
|
+
sessionService.revokeAllUserSessions(session.userId);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
ok: true,
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/** GET /avatar/:userId — Serve avatar image (?s=128 square, ?w=200&h=100, ?w=200) */
|
|
149
|
+
const getAvatar = route.get({
|
|
150
|
+
path: '/avatar/:userId',
|
|
151
|
+
query: ImageQuerySchema,
|
|
152
|
+
handler: (ctx) => {
|
|
153
|
+
const userService = inject(UserService);
|
|
154
|
+
const { userId } = ctx.params as {
|
|
155
|
+
userId: string;
|
|
156
|
+
};
|
|
157
|
+
const avatar = userService.getAvatarData(userId);
|
|
158
|
+
return serveImage(avatar?.data ?? null, ctx);
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
export const profileRoutes = [updateProfile, changePassword, uploadAvatar, removeAvatar, getAvatar];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scope routes — list available scopes (public)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { inject } from '@brika/di';
|
|
6
|
+
import { route } from '@brika/router';
|
|
7
|
+
import { ScopeService } from '../../services/ScopeService';
|
|
8
|
+
|
|
9
|
+
/** GET /scopes — List all available scopes */
|
|
10
|
+
const listScopes = route.get({
|
|
11
|
+
path: '/scopes',
|
|
12
|
+
handler: () => {
|
|
13
|
+
const scopeService = inject(ScopeService);
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
scopes: scopeService.getRegistry(),
|
|
17
|
+
categories: ['admin', 'workflow', 'board', 'plugin', 'settings'],
|
|
18
|
+
};
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const scopeRoutes = [listScopes];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session management routes — list, revoke
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { inject } from '@brika/di';
|
|
6
|
+
import { Forbidden, route } from '@brika/router';
|
|
7
|
+
import { canAccess } from '../../middleware/canAccess';
|
|
8
|
+
import { SessionService } from '../../services/SessionService';
|
|
9
|
+
import { Scope } from '../../types';
|
|
10
|
+
import { requireSession } from '../requireSession';
|
|
11
|
+
|
|
12
|
+
/** GET /sessions — List active sessions for current user */
|
|
13
|
+
const listSessions = route.get({
|
|
14
|
+
path: '/sessions',
|
|
15
|
+
handler: (ctx) => {
|
|
16
|
+
const session = requireSession(ctx);
|
|
17
|
+
const sessionService = inject(SessionService);
|
|
18
|
+
const sessions = sessionService.listUserSessions(session.userId);
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
sessions: sessions.map((s) => ({
|
|
22
|
+
id: s.id,
|
|
23
|
+
ip: s.ip,
|
|
24
|
+
userAgent: s.userAgent,
|
|
25
|
+
createdAt: s.createdAt,
|
|
26
|
+
lastSeenAt: s.lastSeenAt,
|
|
27
|
+
current: s.id === session.id,
|
|
28
|
+
})),
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** DELETE /sessions/:id — Revoke a specific session */
|
|
34
|
+
const revokeSession = route.delete({
|
|
35
|
+
path: '/sessions/:id',
|
|
36
|
+
handler: (ctx) => {
|
|
37
|
+
const session = requireSession(ctx);
|
|
38
|
+
const { id: sessionId } = ctx.params as {
|
|
39
|
+
id: string;
|
|
40
|
+
};
|
|
41
|
+
const sessionService = inject(SessionService);
|
|
42
|
+
|
|
43
|
+
const isOwn = sessionService.listUserSessions(session.userId).some((s) => s.id === sessionId);
|
|
44
|
+
if (!isOwn && !canAccess(session.scopes, Scope.ADMIN_ALL)) {
|
|
45
|
+
throw new Forbidden();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
sessionService.revokeSession(sessionId);
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** DELETE /sessions — Revoke all sessions for current user (signs out everywhere) */
|
|
56
|
+
const revokeAllSessions = route.delete({
|
|
57
|
+
path: '/sessions',
|
|
58
|
+
handler: (ctx) => {
|
|
59
|
+
const session = requireSession(ctx);
|
|
60
|
+
const sessionService = inject(SessionService);
|
|
61
|
+
sessionService.revokeAllUserSessions(session.userId);
|
|
62
|
+
return {
|
|
63
|
+
ok: true,
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const sessionRoutes = [listSessions, revokeSession, revokeAllSessions];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Setup routes — First-run admin creation
|
|
3
|
+
*
|
|
4
|
+
* These routes are public (no auth required) but locked once an admin exists.
|
|
5
|
+
* Setup status is served by the hub-level route at /api/setup/status which
|
|
6
|
+
* combines hasAdmin() with the hub's setupCompleted flag.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { inject } from '@brika/di';
|
|
10
|
+
import { Conflict, rateLimit, route } from '@brika/router';
|
|
11
|
+
import { Role } from '../../roles';
|
|
12
|
+
import { SetupSchema } from '../../schemas';
|
|
13
|
+
import { AuthService } from '../../services/AuthService';
|
|
14
|
+
import { UserService } from '../../services/UserService';
|
|
15
|
+
import { sessionCookie } from './cookie';
|
|
16
|
+
|
|
17
|
+
/** POST / — Create the first admin account */
|
|
18
|
+
const createAdmin = route.post({
|
|
19
|
+
path: '/',
|
|
20
|
+
middleware: [rateLimit({ window: 60, max: 3 })],
|
|
21
|
+
body: SetupSchema,
|
|
22
|
+
handler: async (ctx) => {
|
|
23
|
+
const userService = inject(UserService);
|
|
24
|
+
const authService = inject(AuthService);
|
|
25
|
+
|
|
26
|
+
if (userService.hasAdmin()) {
|
|
27
|
+
throw new Conflict('Setup already completed');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { email, name, password } = ctx.body;
|
|
31
|
+
const user = userService.createUser(email, name, Role.ADMIN);
|
|
32
|
+
await userService.setPassword(user.id, password);
|
|
33
|
+
|
|
34
|
+
// Auto-login: create session and set cookie
|
|
35
|
+
const ip =
|
|
36
|
+
ctx.req.headers.get('x-forwarded-for') ?? ctx.req.headers.get('x-real-ip') ?? undefined;
|
|
37
|
+
const userAgent = ctx.req.headers.get('user-agent') ?? undefined;
|
|
38
|
+
const result = await authService.login(email, password, ip, userAgent);
|
|
39
|
+
|
|
40
|
+
return new Response(JSON.stringify({ user: result.user }), {
|
|
41
|
+
status: 201,
|
|
42
|
+
headers: {
|
|
43
|
+
'Content-Type': 'application/json',
|
|
44
|
+
'Set-Cookie': sessionCookie(result.token, result.expiresIn),
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const setupRoutes = [createAdmin];
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User admin routes — list, create, get, update, delete, reset password
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { inject } from '@brika/di';
|
|
6
|
+
import { BadRequest, Forbidden, group, NotFound, route } from '@brika/router';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { canAccess } from '../../middleware/canAccess';
|
|
9
|
+
import { requireAuth } from '../../middleware/requireAuth';
|
|
10
|
+
import {
|
|
11
|
+
CreateUserSchema,
|
|
12
|
+
NameSchema,
|
|
13
|
+
PasswordSchema,
|
|
14
|
+
RoleSchema,
|
|
15
|
+
ScopeSchema,
|
|
16
|
+
} from '../../schemas';
|
|
17
|
+
import { SessionService } from '../../services/SessionService';
|
|
18
|
+
import { UserService } from '../../services/UserService';
|
|
19
|
+
import { Scope } from '../../types';
|
|
20
|
+
import { requireSession } from '../requireSession';
|
|
21
|
+
|
|
22
|
+
const UpdateUserSchema = z.object({
|
|
23
|
+
name: NameSchema.optional(),
|
|
24
|
+
role: RoleSchema.optional(),
|
|
25
|
+
isActive: z.boolean().optional(),
|
|
26
|
+
scopes: z.array(ScopeSchema).optional(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const ResetPasswordSchema = z.object({
|
|
30
|
+
password: PasswordSchema,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** GET / — List all users (admin only) */
|
|
34
|
+
const listUsers = route.get({
|
|
35
|
+
path: '/',
|
|
36
|
+
handler: (ctx) => {
|
|
37
|
+
requireSession(ctx, Scope.ADMIN_ALL);
|
|
38
|
+
|
|
39
|
+
const userService = inject(UserService);
|
|
40
|
+
return {
|
|
41
|
+
users: userService.listUsers(),
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** POST / — Create new user (admin only) */
|
|
47
|
+
const createUser = route.post({
|
|
48
|
+
path: '/',
|
|
49
|
+
body: CreateUserSchema,
|
|
50
|
+
handler: async (ctx) => {
|
|
51
|
+
requireSession(ctx, Scope.ADMIN_ALL);
|
|
52
|
+
|
|
53
|
+
const userService = inject(UserService);
|
|
54
|
+
const { email, name, role, password } = ctx.body;
|
|
55
|
+
const user = userService.createUser(email, name, role);
|
|
56
|
+
|
|
57
|
+
if (password) {
|
|
58
|
+
await userService.setPassword(user.id, password);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
status: 201,
|
|
63
|
+
body: {
|
|
64
|
+
user,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/** GET /:id — Get user by ID (admin or self) */
|
|
71
|
+
const getUser = route.get({
|
|
72
|
+
path: '/:id',
|
|
73
|
+
handler: (ctx) => {
|
|
74
|
+
const session = requireSession(ctx);
|
|
75
|
+
const { id: userId } = ctx.params as {
|
|
76
|
+
id: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (!canAccess(session.scopes, Scope.ADMIN_ALL) && session.userId !== userId) {
|
|
80
|
+
throw new Forbidden();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const userService = inject(UserService);
|
|
84
|
+
const user = userService.getUser(userId);
|
|
85
|
+
if (!user) {
|
|
86
|
+
throw new NotFound('User not found');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
user,
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** PUT /:id/password — Reset user password (admin only) */
|
|
96
|
+
const resetPassword = route.put({
|
|
97
|
+
path: '/:id/password',
|
|
98
|
+
body: ResetPasswordSchema,
|
|
99
|
+
handler: async (ctx) => {
|
|
100
|
+
requireSession(ctx, Scope.ADMIN_ALL);
|
|
101
|
+
|
|
102
|
+
const userService = inject(UserService);
|
|
103
|
+
const sessionService = inject(SessionService);
|
|
104
|
+
const { id: userId } = ctx.params as {
|
|
105
|
+
id: string;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const user = userService.getUser(userId);
|
|
109
|
+
if (!user) {
|
|
110
|
+
throw new NotFound('User not found');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await userService.setPassword(userId, ctx.body.password);
|
|
114
|
+
// Revoke all sessions after admin password reset to invalidate potentially compromised sessions
|
|
115
|
+
sessionService.revokeAllUserSessions(userId);
|
|
116
|
+
return {
|
|
117
|
+
ok: true,
|
|
118
|
+
};
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/** PUT /:id — Update user (admin only) */
|
|
123
|
+
const updateUser = route.put({
|
|
124
|
+
path: '/:id',
|
|
125
|
+
body: UpdateUserSchema,
|
|
126
|
+
handler: (ctx) => {
|
|
127
|
+
requireSession(ctx, Scope.ADMIN_ALL);
|
|
128
|
+
|
|
129
|
+
const userService = inject(UserService);
|
|
130
|
+
const { id: userId } = ctx.params as {
|
|
131
|
+
id: string;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const user = userService.updateUser(userId, ctx.body);
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
user,
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
/** DELETE /:id — Delete user (admin only) */
|
|
143
|
+
const deleteUser = route.delete({
|
|
144
|
+
path: '/:id',
|
|
145
|
+
handler: (ctx) => {
|
|
146
|
+
const session = requireSession(ctx, Scope.ADMIN_ALL);
|
|
147
|
+
|
|
148
|
+
const { id: userId } = ctx.params as {
|
|
149
|
+
id: string;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
if (session.userId === userId) {
|
|
153
|
+
throw new BadRequest('Cannot delete your own account');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const userService = inject(UserService);
|
|
157
|
+
const sessionService = inject(SessionService);
|
|
158
|
+
const user = userService.getUser(userId);
|
|
159
|
+
if (!user) {
|
|
160
|
+
throw new NotFound('User not found');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
sessionService.revokeAllUserSessions(userId);
|
|
164
|
+
userService.deleteUser(user.email);
|
|
165
|
+
return {
|
|
166
|
+
ok: true,
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
export const userRoutes = group({
|
|
172
|
+
prefix: '/api/users',
|
|
173
|
+
middleware: [requireAuth()],
|
|
174
|
+
routes: [listUsers, createUser, resetPassword, updateUser, deleteUser, getUser],
|
|
175
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth/server - serveImage
|
|
3
|
+
*
|
|
4
|
+
* Serve binary image data as a Response with resize, cache, and ETag support.
|
|
5
|
+
*
|
|
6
|
+
* Query params (validated via ImageQuerySchema in the route):
|
|
7
|
+
* - `?s=128` → square resize (128×128 cover crop)
|
|
8
|
+
* - `?w=200&h=100` → explicit width/height (cover crop)
|
|
9
|
+
* - `?w=200` → resize width, keep aspect ratio
|
|
10
|
+
* - `?h=100` → resize height, keep aspect ratio
|
|
11
|
+
*
|
|
12
|
+
* All output as webp. Cache-Control + ETag for caching. 304 on match.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { photon } from '@brika/photon';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
|
|
18
|
+
const MAX_PX = 2048;
|
|
19
|
+
const dim = z.coerce.number().int().min(1).max(MAX_PX).optional();
|
|
20
|
+
|
|
21
|
+
export const ImageQuerySchema = z.object({
|
|
22
|
+
w: dim,
|
|
23
|
+
h: dim,
|
|
24
|
+
s: dim,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export type ImageQuery = z.infer<typeof ImageQuerySchema>;
|
|
28
|
+
|
|
29
|
+
interface ServeImageOptions {
|
|
30
|
+
maxAge?: number;
|
|
31
|
+
immutable?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function serveImage(
|
|
35
|
+
data: Buffer | null,
|
|
36
|
+
ctx: {
|
|
37
|
+
req: Request;
|
|
38
|
+
query: ImageQuery;
|
|
39
|
+
},
|
|
40
|
+
options?: ServeImageOptions
|
|
41
|
+
): Response {
|
|
42
|
+
if (!data) {
|
|
43
|
+
return new Response(null, {
|
|
44
|
+
status: 204,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const maxAge = options?.maxAge ?? 31536000;
|
|
49
|
+
const { s, w, h } = ctx.query ?? {};
|
|
50
|
+
const width = s ?? w;
|
|
51
|
+
const height = s ?? h;
|
|
52
|
+
|
|
53
|
+
let output: Buffer = data;
|
|
54
|
+
if (width ?? height) {
|
|
55
|
+
const fit = width && height ? 'cover' : 'contain';
|
|
56
|
+
output = photon(data)
|
|
57
|
+
.resize({
|
|
58
|
+
width,
|
|
59
|
+
height,
|
|
60
|
+
fit,
|
|
61
|
+
})
|
|
62
|
+
.webp()
|
|
63
|
+
.toBuffer();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const etag = `"${Bun.hash(output).toString(36)}"`;
|
|
67
|
+
|
|
68
|
+
if (ctx.req.headers.get('if-none-match') === etag) {
|
|
69
|
+
return new Response(null, {
|
|
70
|
+
status: 304,
|
|
71
|
+
headers: {
|
|
72
|
+
ETag: etag,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const useImmutable = options?.immutable ?? maxAge > 0;
|
|
78
|
+
let cacheControl = 'no-cache';
|
|
79
|
+
if (maxAge > 0) {
|
|
80
|
+
const suffix = useImmutable ? ', immutable' : ', must-revalidate';
|
|
81
|
+
cacheControl = `public, max-age=${maxAge}${suffix}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return new Response(new Uint8Array(output), {
|
|
85
|
+
headers: {
|
|
86
|
+
'Content-Type': 'image/webp',
|
|
87
|
+
'Cache-Control': cacheControl,
|
|
88
|
+
ETag: etag,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @brika/auth - AuthService
|
|
3
|
+
* Handles authentication operations using server-side sessions.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { inject, injectable } from '@brika/di';
|
|
7
|
+
import { LoginResponse, User } from '../types';
|
|
8
|
+
import { ScopeService } from './ScopeService';
|
|
9
|
+
import { SessionService } from './SessionService';
|
|
10
|
+
import { UserService } from './UserService';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Service for authentication operations
|
|
14
|
+
*/
|
|
15
|
+
@injectable()
|
|
16
|
+
export class AuthService {
|
|
17
|
+
private readonly sessionService: SessionService;
|
|
18
|
+
private readonly userService: UserService;
|
|
19
|
+
private readonly scopeService: ScopeService;
|
|
20
|
+
|
|
21
|
+
constructor(userService?: UserService) {
|
|
22
|
+
if (userService) {
|
|
23
|
+
this.userService = userService;
|
|
24
|
+
this.sessionService = inject(SessionService);
|
|
25
|
+
this.scopeService = inject(ScopeService);
|
|
26
|
+
} else {
|
|
27
|
+
this.sessionService = inject(SessionService);
|
|
28
|
+
this.userService = inject(UserService);
|
|
29
|
+
this.scopeService = inject(ScopeService);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Login with email and password.
|
|
35
|
+
* Creates a server-side session and returns the raw token.
|
|
36
|
+
*/
|
|
37
|
+
async login(
|
|
38
|
+
email: string,
|
|
39
|
+
password: string,
|
|
40
|
+
ip?: string,
|
|
41
|
+
userAgent?: string
|
|
42
|
+
): Promise<LoginResponse> {
|
|
43
|
+
const user = this.userService.getUserByEmail(email);
|
|
44
|
+
if (!user) {
|
|
45
|
+
throw new Error('Invalid credentials');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Deactivated users must not be allowed to log in
|
|
49
|
+
if (!user.isActive) {
|
|
50
|
+
throw new Error('Invalid credentials');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const passwordValid = await this.userService.verifyPassword(user.id, password);
|
|
54
|
+
if (!passwordValid) {
|
|
55
|
+
throw new Error('Invalid credentials');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const token = this.sessionService.createSession(user.id, ip, userAgent);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
token,
|
|
62
|
+
user,
|
|
63
|
+
expiresIn: this.sessionService.getSessionTTL(),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Logout — revoke the current session.
|
|
69
|
+
*/
|
|
70
|
+
logout(sessionId: string): void {
|
|
71
|
+
this.sessionService.revokeSession(sessionId);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get current user from user ID.
|
|
76
|
+
*/
|
|
77
|
+
getCurrentUser(userId: string): User | null {
|
|
78
|
+
return this.userService.getUser(userId);
|
|
79
|
+
}
|
|
80
|
+
}
|