@lastshotlabs/bunshot 0.0.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/CLAUDE.md +102 -0
- package/README.md +1458 -0
- package/bun.lock +170 -0
- package/package.json +47 -0
- package/src/adapters/memoryAuth.ts +240 -0
- package/src/adapters/mongoAuth.ts +91 -0
- package/src/adapters/sqliteAuth.ts +320 -0
- package/src/app.ts +368 -0
- package/src/cli.ts +265 -0
- package/src/index.ts +52 -0
- package/src/lib/HttpError.ts +5 -0
- package/src/lib/appConfig.ts +29 -0
- package/src/lib/authAdapter.ts +46 -0
- package/src/lib/authRateLimit.ts +104 -0
- package/src/lib/constants.ts +2 -0
- package/src/lib/context.ts +17 -0
- package/src/lib/emailVerification.ts +105 -0
- package/src/lib/fingerprint.ts +43 -0
- package/src/lib/jwt.ts +17 -0
- package/src/lib/logger.ts +9 -0
- package/src/lib/mongo.ts +70 -0
- package/src/lib/oauth.ts +114 -0
- package/src/lib/queue.ts +18 -0
- package/src/lib/redis.ts +45 -0
- package/src/lib/roles.ts +23 -0
- package/src/lib/session.ts +91 -0
- package/src/lib/validate.ts +14 -0
- package/src/lib/ws.ts +82 -0
- package/src/middleware/bearerAuth.ts +15 -0
- package/src/middleware/botProtection.ts +73 -0
- package/src/middleware/cacheResponse.ts +189 -0
- package/src/middleware/cors.ts +19 -0
- package/src/middleware/errorHandler.ts +14 -0
- package/src/middleware/identify.ts +36 -0
- package/src/middleware/index.ts +8 -0
- package/src/middleware/logger.ts +9 -0
- package/src/middleware/rateLimit.ts +37 -0
- package/src/middleware/requireRole.ts +42 -0
- package/src/middleware/requireVerifiedEmail.ts +31 -0
- package/src/middleware/userAuth.ts +9 -0
- package/src/models/AuthUser.ts +17 -0
- package/src/routes/auth.ts +245 -0
- package/src/routes/health.ts +27 -0
- package/src/routes/home.ts +21 -0
- package/src/routes/oauth.ts +174 -0
- package/src/schemas/auth.ts +14 -0
- package/src/server.ts +91 -0
- package/src/services/auth.ts +59 -0
- package/src/ws/index.ts +42 -0
- package/tsconfig.json +43 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createRoute } from "@hono/zod-openapi";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { createRouter } from "@lib/context";
|
|
4
|
+
|
|
5
|
+
export const router = createRouter();
|
|
6
|
+
|
|
7
|
+
router.openapi(
|
|
8
|
+
createRoute({
|
|
9
|
+
method: "get",
|
|
10
|
+
path: "/health",
|
|
11
|
+
tags: ["Core"],
|
|
12
|
+
responses: {
|
|
13
|
+
200: {
|
|
14
|
+
content: {
|
|
15
|
+
"application/json": {
|
|
16
|
+
schema: z.object({
|
|
17
|
+
status: z.enum(["ok"]),
|
|
18
|
+
timestamp: z.string(),
|
|
19
|
+
}),
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
description: "Service health check",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
}),
|
|
26
|
+
(c) => c.json({ status: "ok" as "ok", timestamp: new Date().toISOString() })
|
|
27
|
+
);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createRoute } from "@hono/zod-openapi";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getAppName } from "@lib/appConfig";
|
|
4
|
+
import { createRouter } from "@lib/context";
|
|
5
|
+
|
|
6
|
+
export const router = createRouter();
|
|
7
|
+
|
|
8
|
+
router.openapi(
|
|
9
|
+
createRoute({
|
|
10
|
+
method: "get",
|
|
11
|
+
path: "/",
|
|
12
|
+
tags: ["Core"],
|
|
13
|
+
responses: {
|
|
14
|
+
200: {
|
|
15
|
+
content: { "application/json": { schema: z.object({ message: z.string() }) } },
|
|
16
|
+
description: "API is running",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
}),
|
|
20
|
+
(c) => c.json({ message: `${getAppName()} is running` })
|
|
21
|
+
);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { createRouter } from "@lib/context";
|
|
2
|
+
import { setCookie } from "hono/cookie";
|
|
3
|
+
import { decodeIdToken } from "arctic";
|
|
4
|
+
import type { Context } from "hono";
|
|
5
|
+
import type { AppEnv } from "@lib/context";
|
|
6
|
+
import {
|
|
7
|
+
getGoogle, getApple,
|
|
8
|
+
storeOAuthState, consumeOAuthState,
|
|
9
|
+
generateState, generateCodeVerifier,
|
|
10
|
+
} from "@lib/oauth";
|
|
11
|
+
import { getAuthAdapter } from "@lib/authAdapter";
|
|
12
|
+
import { HttpError } from "@lib/HttpError";
|
|
13
|
+
import { signToken } from "@lib/jwt";
|
|
14
|
+
import { createSession } from "@lib/session";
|
|
15
|
+
import { COOKIE_TOKEN } from "@lib/constants";
|
|
16
|
+
import { userAuth } from "@middleware/userAuth";
|
|
17
|
+
import { getDefaultRole } from "@lib/appConfig";
|
|
18
|
+
|
|
19
|
+
const isProd = process.env.NODE_ENV === "production";
|
|
20
|
+
|
|
21
|
+
const cookieOptions = {
|
|
22
|
+
httpOnly: true,
|
|
23
|
+
secure: isProd,
|
|
24
|
+
sameSite: "Lax" as const,
|
|
25
|
+
path: "/",
|
|
26
|
+
maxAge: 60 * 60 * 24 * 7,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const finishOAuth = async (
|
|
30
|
+
c: Context<AppEnv>,
|
|
31
|
+
provider: string,
|
|
32
|
+
providerId: string,
|
|
33
|
+
profile: { email?: string; name?: string; avatarUrl?: string },
|
|
34
|
+
postLoginRedirect: string,
|
|
35
|
+
) => {
|
|
36
|
+
const adapter = getAuthAdapter();
|
|
37
|
+
if (!adapter.findOrCreateByProvider) {
|
|
38
|
+
return c.json({ error: "Auth adapter does not support social login" }, 500);
|
|
39
|
+
}
|
|
40
|
+
let user: { id: string; created: boolean };
|
|
41
|
+
try {
|
|
42
|
+
user = await adapter.findOrCreateByProvider(provider, providerId, profile);
|
|
43
|
+
} catch (err) {
|
|
44
|
+
const message = err instanceof HttpError ? err.message : "Authentication failed";
|
|
45
|
+
const sep = postLoginRedirect.includes("?") ? "&" : "?";
|
|
46
|
+
return c.redirect(`${postLoginRedirect}${sep}error=${encodeURIComponent(message)}`);
|
|
47
|
+
}
|
|
48
|
+
if (user.created) {
|
|
49
|
+
const role = getDefaultRole();
|
|
50
|
+
if (role && adapter.setRoles) await adapter.setRoles(user.id, [role]);
|
|
51
|
+
}
|
|
52
|
+
const token = await signToken(user.id);
|
|
53
|
+
await createSession(user.id, token);
|
|
54
|
+
setCookie(c, COOKIE_TOKEN, token, cookieOptions);
|
|
55
|
+
|
|
56
|
+
// Append token to redirect so non-browser clients (mobile deep links) can extract it.
|
|
57
|
+
// Browser apps can safely ignore the query param.
|
|
58
|
+
try {
|
|
59
|
+
const url = new URL(postLoginRedirect);
|
|
60
|
+
url.searchParams.set("token", token);
|
|
61
|
+
if (profile.email) url.searchParams.set("user", profile.email);
|
|
62
|
+
return c.redirect(url.toString());
|
|
63
|
+
} catch {
|
|
64
|
+
// Relative path fallback
|
|
65
|
+
const sep = postLoginRedirect.includes("?") ? "&" : "?";
|
|
66
|
+
const userParam = profile.email ? `&user=${encodeURIComponent(profile.email)}` : "";
|
|
67
|
+
return c.redirect(`${postLoginRedirect}${sep}token=${token}${userParam}`);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const createOAuthRouter = (providers: string[], postLoginRedirect: string) => {
|
|
72
|
+
const router = createRouter();
|
|
73
|
+
|
|
74
|
+
// ─── Google ───────────────────────────────────────────────────────────────
|
|
75
|
+
if (providers.includes("google")) {
|
|
76
|
+
router.get("/auth/google", async (c) => {
|
|
77
|
+
const state = generateState();
|
|
78
|
+
const codeVerifier = generateCodeVerifier();
|
|
79
|
+
await storeOAuthState(state, codeVerifier);
|
|
80
|
+
const url = getGoogle().createAuthorizationURL(state, codeVerifier, ["openid", "profile", "email"]);
|
|
81
|
+
return c.redirect(url.toString());
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
router.get("/auth/google/callback", async (c) => {
|
|
85
|
+
const { code, state } = c.req.query();
|
|
86
|
+
if (!code || !state) return c.json({ error: "Invalid callback" }, 400);
|
|
87
|
+
|
|
88
|
+
const stored = await consumeOAuthState(state);
|
|
89
|
+
if (!stored?.codeVerifier) return c.json({ error: "Invalid or expired state" }, 400);
|
|
90
|
+
|
|
91
|
+
const tokens = await getGoogle().validateAuthorizationCode(code, stored.codeVerifier);
|
|
92
|
+
const info = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
|
|
93
|
+
headers: { Authorization: `Bearer ${tokens.accessToken()}` },
|
|
94
|
+
}).then((r) => r.json()) as { sub: string; email?: string; name?: string; picture?: string };
|
|
95
|
+
|
|
96
|
+
if (stored.linkUserId) {
|
|
97
|
+
const adapter = getAuthAdapter();
|
|
98
|
+
if (!adapter.linkProvider) return c.json({ error: "Auth adapter does not support linkProvider" }, 500);
|
|
99
|
+
await adapter.linkProvider(stored.linkUserId, "google", info.sub);
|
|
100
|
+
const sep = postLoginRedirect.includes("?") ? "&" : "?";
|
|
101
|
+
return c.redirect(`${postLoginRedirect}${sep}linked=google`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return finishOAuth(c, "google", info.sub, { email: info.email, name: info.name, avatarUrl: info.picture }, postLoginRedirect);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
router.get("/auth/google/link", userAuth, async (c) => {
|
|
108
|
+
const state = generateState();
|
|
109
|
+
const codeVerifier = generateCodeVerifier();
|
|
110
|
+
await storeOAuthState(state, codeVerifier, c.get("authUserId")!);
|
|
111
|
+
const url = getGoogle().createAuthorizationURL(state, codeVerifier, ["openid", "profile", "email"]);
|
|
112
|
+
return c.redirect(url.toString());
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
router.delete("/auth/google/link", userAuth, async (c) => {
|
|
116
|
+
const adapter = getAuthAdapter();
|
|
117
|
+
if (!adapter.unlinkProvider) {
|
|
118
|
+
return c.json({ error: "Auth adapter does not support unlinkProvider" }, 500);
|
|
119
|
+
}
|
|
120
|
+
await adapter.unlinkProvider(c.get("authUserId")!, "google");
|
|
121
|
+
return c.body(null, 204);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Apple ────────────────────────────────────────────────────────────────
|
|
126
|
+
if (providers.includes("apple")) {
|
|
127
|
+
router.get("/auth/apple", async (c) => {
|
|
128
|
+
const state = generateState();
|
|
129
|
+
await storeOAuthState(state);
|
|
130
|
+
const url = getApple().createAuthorizationURL(state, ["name", "email"]);
|
|
131
|
+
return c.redirect(url.toString());
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Apple sends a POST with form data to the callback URL
|
|
135
|
+
router.post("/auth/apple/callback", async (c) => {
|
|
136
|
+
const form = await c.req.formData();
|
|
137
|
+
const code = form.get("code") as string | null;
|
|
138
|
+
const state = form.get("state") as string | null;
|
|
139
|
+
if (!code || !state) return c.json({ error: "Invalid callback" }, 400);
|
|
140
|
+
|
|
141
|
+
const stored = await consumeOAuthState(state);
|
|
142
|
+
if (!stored) return c.json({ error: "Invalid or expired state" }, 400);
|
|
143
|
+
|
|
144
|
+
const tokens = await getApple().validateAuthorizationCode(code);
|
|
145
|
+
const claims = decodeIdToken(tokens.idToken()) as { sub: string; email?: string };
|
|
146
|
+
|
|
147
|
+
if (stored.linkUserId) {
|
|
148
|
+
const adapter = getAuthAdapter();
|
|
149
|
+
if (!adapter.linkProvider) return c.json({ error: "Auth adapter does not support linkProvider" }, 500);
|
|
150
|
+
await adapter.linkProvider(stored.linkUserId, "apple", claims.sub);
|
|
151
|
+
const sep = postLoginRedirect.includes("?") ? "&" : "?";
|
|
152
|
+
return c.redirect(`${postLoginRedirect}${sep}linked=apple`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Apple only sends name on the very first sign-in
|
|
156
|
+
const userJSON = form.get("user") as string | null;
|
|
157
|
+
const userInfo = userJSON ? JSON.parse(userJSON) as { name?: { firstName?: string; lastName?: string } } : {};
|
|
158
|
+
const name = userInfo.name
|
|
159
|
+
? `${userInfo.name.firstName ?? ""} ${userInfo.name.lastName ?? ""}`.trim() || undefined
|
|
160
|
+
: undefined;
|
|
161
|
+
|
|
162
|
+
return finishOAuth(c, "apple", claims.sub, { email: claims.email, name }, postLoginRedirect);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
router.get("/auth/apple/link", userAuth, async (c) => {
|
|
166
|
+
const state = generateState();
|
|
167
|
+
await storeOAuthState(state, undefined, c.get("authUserId")!);
|
|
168
|
+
const url = getApple().createAuthorizationURL(state, ["name", "email"]);
|
|
169
|
+
return c.redirect(url.toString());
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return router;
|
|
174
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { PrimaryField } from "@lib/appConfig";
|
|
3
|
+
|
|
4
|
+
export const makeRegisterSchema = (primaryField: PrimaryField) =>
|
|
5
|
+
z.object({
|
|
6
|
+
[primaryField]: primaryField === "email" ? z.string().email() : z.string().min(3),
|
|
7
|
+
password: z.string().min(8),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const makeLoginSchema = (primaryField: PrimaryField) =>
|
|
11
|
+
z.object({
|
|
12
|
+
[primaryField]: primaryField === "email" ? z.string().email() : z.string().min(1),
|
|
13
|
+
password: z.string().min(1),
|
|
14
|
+
});
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
|
|
2
|
+
import { createApp, type CreateAppConfig } from "./app";
|
|
3
|
+
import { websocket as defaultWebsocket, createWsUpgradeHandler, type SocketData } from "@ws/index";
|
|
4
|
+
import { setWsServer, handleRoomActions, cleanupSocket } from "@lib/ws";
|
|
5
|
+
import { log } from "@lib/logger";
|
|
6
|
+
|
|
7
|
+
export interface WsConfig<T extends object = object> {
|
|
8
|
+
/** Override or extend the default WebSocket handler */
|
|
9
|
+
handler?: WebSocketHandler<SocketData<T>>;
|
|
10
|
+
/** Override the default /ws upgrade handler (auth + upgrade logic) */
|
|
11
|
+
upgradeHandler?: (req: Request, server: Server<SocketData<T>>) => Promise<Response | undefined>;
|
|
12
|
+
/**
|
|
13
|
+
* Guard called before a socket joins a room via the subscribe action.
|
|
14
|
+
* Return true to allow, false to deny (client receives { event: "subscribe_denied", room }).
|
|
15
|
+
* ws.data.userId is available for auth checks.
|
|
16
|
+
*/
|
|
17
|
+
onRoomSubscribe?: (ws: ServerWebSocket<SocketData<T>>, room: string) => boolean | Promise<boolean>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CreateServerConfig<T extends object = object> extends CreateAppConfig {
|
|
21
|
+
port?: number;
|
|
22
|
+
/** Absolute path to the service's workers directory — auto-imports all .ts files */
|
|
23
|
+
workersDir?: string;
|
|
24
|
+
/** Set false to disable auto-loading workers. Defaults to true */
|
|
25
|
+
enableWorkers?: boolean;
|
|
26
|
+
/** WebSocket configuration */
|
|
27
|
+
ws?: WsConfig<T>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const createServer = async <T extends object = object>(
|
|
31
|
+
config: CreateServerConfig<T>
|
|
32
|
+
): Promise<Server<SocketData<T>>> => {
|
|
33
|
+
const app = await createApp(config);
|
|
34
|
+
const port = Number(process.env.PORT ?? config.port ?? 3000);
|
|
35
|
+
const { workersDir, enableWorkers = true, ws: wsConfig = {} } = config;
|
|
36
|
+
const { handler: userWs, upgradeHandler: wsUpgradeHandler, onRoomSubscribe } = wsConfig;
|
|
37
|
+
|
|
38
|
+
// Default handlers are typed for the base SocketData — cast is safe because
|
|
39
|
+
// they only access id/userId/rooms which exist in every SocketData<T>.
|
|
40
|
+
type SD = SocketData<T>;
|
|
41
|
+
const defaultOpen = defaultWebsocket.open as WebSocketHandler<SD>["open"];
|
|
42
|
+
const defaultMessage = defaultWebsocket.message as WebSocketHandler<SD>["message"];
|
|
43
|
+
const defaultClose = defaultWebsocket.close as WebSocketHandler<SD>["close"];
|
|
44
|
+
const defaultDrain = defaultWebsocket.drain as WebSocketHandler<SD>["drain"];
|
|
45
|
+
|
|
46
|
+
const ws: WebSocketHandler<SD> = {
|
|
47
|
+
open: userWs?.open ?? defaultOpen,
|
|
48
|
+
async message(socket, message) {
|
|
49
|
+
if (!await handleRoomActions(socket, message, onRoomSubscribe)) {
|
|
50
|
+
(userWs?.message ?? defaultMessage!)(socket, message);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
close(socket, code, reason) {
|
|
54
|
+
cleanupSocket(socket.data.id, socket.data.rooms);
|
|
55
|
+
socket.data.rooms.clear();
|
|
56
|
+
(userWs?.close ?? defaultClose!)(socket, code, reason);
|
|
57
|
+
},
|
|
58
|
+
drain: userWs?.drain ?? defaultDrain,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
let server: Server<SD>;
|
|
62
|
+
|
|
63
|
+
server = Bun.serve<SD>({
|
|
64
|
+
port,
|
|
65
|
+
routes: {
|
|
66
|
+
"/ws": (req: Request) => wsUpgradeHandler
|
|
67
|
+
? wsUpgradeHandler(req, server)
|
|
68
|
+
: createWsUpgradeHandler(server as Server<SocketData>)(req),
|
|
69
|
+
},
|
|
70
|
+
fetch: app.fetch,
|
|
71
|
+
websocket: ws,
|
|
72
|
+
error(err) {
|
|
73
|
+
console.error(err);
|
|
74
|
+
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
setWsServer(server);
|
|
79
|
+
|
|
80
|
+
if (enableWorkers && workersDir) {
|
|
81
|
+
const glob = new Bun.Glob("**/*.ts");
|
|
82
|
+
for await (const file of glob.scan({ cwd: workersDir })) {
|
|
83
|
+
await import(`${workersDir}/${file}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
log(`[server] running at http://localhost:${server.port}`);
|
|
88
|
+
log(`[server] API docs at http://localhost:${server.port}/docs`);
|
|
89
|
+
|
|
90
|
+
return server;
|
|
91
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { getAuthAdapter } from "@lib/authAdapter";
|
|
2
|
+
import { HttpError } from "@lib/HttpError";
|
|
3
|
+
import { signToken, verifyToken } from "@lib/jwt";
|
|
4
|
+
import { createSession, deleteSession } from "@lib/session";
|
|
5
|
+
import { getDefaultRole, getPrimaryField, getEmailVerificationConfig } from "@lib/appConfig";
|
|
6
|
+
import { createVerificationToken } from "@lib/emailVerification";
|
|
7
|
+
|
|
8
|
+
export const register = async (identifier: string, password: string): Promise<string> => {
|
|
9
|
+
const hashed = await Bun.password.hash(password);
|
|
10
|
+
const adapter = getAuthAdapter();
|
|
11
|
+
const user = await adapter.create(identifier, hashed);
|
|
12
|
+
const role = getDefaultRole();
|
|
13
|
+
if (role) await adapter.setRoles!(user.id, [role]);
|
|
14
|
+
const token = await signToken(user.id);
|
|
15
|
+
await createSession(user.id, token);
|
|
16
|
+
|
|
17
|
+
const evConfig = getEmailVerificationConfig();
|
|
18
|
+
if (evConfig && getPrimaryField() === "email") {
|
|
19
|
+
try {
|
|
20
|
+
const verificationToken = await createVerificationToken(user.id, identifier);
|
|
21
|
+
await evConfig.onSend(identifier, verificationToken);
|
|
22
|
+
} catch (e) {
|
|
23
|
+
console.error("[email-verification] Failed to send verification email:", e);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return token;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const login = async (identifier: string, password: string): Promise<{ token: string; emailVerified?: boolean }> => {
|
|
31
|
+
const adapter = getAuthAdapter();
|
|
32
|
+
const findFn = adapter.findByIdentifier ?? adapter.findByEmail.bind(adapter);
|
|
33
|
+
const user = await findFn(identifier);
|
|
34
|
+
if (!user || !(await Bun.password.verify(password, user.passwordHash))) {
|
|
35
|
+
throw new HttpError(401, "Invalid credentials");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const evConfig = getEmailVerificationConfig();
|
|
39
|
+
if (evConfig && getPrimaryField() === "email" && adapter.getEmailVerified) {
|
|
40
|
+
const verified = await adapter.getEmailVerified(user.id);
|
|
41
|
+
if (evConfig.required && !verified) {
|
|
42
|
+
throw new HttpError(403, "Email not verified");
|
|
43
|
+
}
|
|
44
|
+
const token = await signToken(user.id);
|
|
45
|
+
await createSession(user.id, token);
|
|
46
|
+
return { token, emailVerified: verified };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const token = await signToken(user.id);
|
|
50
|
+
await createSession(user.id, token);
|
|
51
|
+
return { token };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const logout = async (token: string | null) => {
|
|
55
|
+
if (token) {
|
|
56
|
+
const payload = await verifyToken(token);
|
|
57
|
+
await deleteSession(payload.sub!);
|
|
58
|
+
}
|
|
59
|
+
};
|
package/src/ws/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Server, WebSocketHandler } from "bun";
|
|
2
|
+
import { verifyToken } from "@lib/jwt";
|
|
3
|
+
import { getSession } from "@lib/session";
|
|
4
|
+
import { COOKIE_TOKEN } from "@lib/constants";
|
|
5
|
+
|
|
6
|
+
export type SocketData<T extends object = object> = {
|
|
7
|
+
id: string;
|
|
8
|
+
userId: string | null;
|
|
9
|
+
rooms: Set<string>;
|
|
10
|
+
} & T;
|
|
11
|
+
|
|
12
|
+
type BaseSocketData = SocketData<object>;
|
|
13
|
+
|
|
14
|
+
export const createWsUpgradeHandler = (server: Server<BaseSocketData>) =>
|
|
15
|
+
async (req: Request): Promise<Response | undefined> => {
|
|
16
|
+
let userId: string | null = null;
|
|
17
|
+
try {
|
|
18
|
+
const token = req.headers.get("cookie")
|
|
19
|
+
?.match(new RegExp(`(?:^|;\\s*)${COOKIE_TOKEN}=([^;]+)`))?.[1] ?? null;
|
|
20
|
+
if (token) {
|
|
21
|
+
const payload = await verifyToken(token);
|
|
22
|
+
const stored = await getSession(payload.sub!);
|
|
23
|
+
if (stored === token) userId = payload.sub!;
|
|
24
|
+
}
|
|
25
|
+
} catch { /* unauthenticated — userId stays null */ }
|
|
26
|
+
|
|
27
|
+
const upgraded = server.upgrade(req, { data: { id: crypto.randomUUID(), userId, rooms: new Set() } });
|
|
28
|
+
return upgraded ? undefined : Response.json({ error: "Upgrade failed" }, { status: 400 });
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const websocket: WebSocketHandler<BaseSocketData> = {
|
|
32
|
+
open(ws) {
|
|
33
|
+
console.log(`[ws] connected: ${ws.data.id}`);
|
|
34
|
+
ws.send(JSON.stringify({ event: "connected", id: ws.data.id }));
|
|
35
|
+
},
|
|
36
|
+
message(ws, message) {
|
|
37
|
+
ws.send(message);
|
|
38
|
+
},
|
|
39
|
+
close(ws) {
|
|
40
|
+
console.log(`[ws] disconnected: ${ws.data.id}`);
|
|
41
|
+
},
|
|
42
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"types": [
|
|
4
|
+
"bun"
|
|
5
|
+
],
|
|
6
|
+
"strict": true,
|
|
7
|
+
"moduleResolution": "bundler",
|
|
8
|
+
"module": "ESNext",
|
|
9
|
+
"target": "ESNext",
|
|
10
|
+
"paths": {
|
|
11
|
+
"@lib/*": [
|
|
12
|
+
"./src/lib/*"
|
|
13
|
+
],
|
|
14
|
+
"@middleware/*": [
|
|
15
|
+
"./src/middleware/*"
|
|
16
|
+
],
|
|
17
|
+
"@models/*": [
|
|
18
|
+
"./src/models/*"
|
|
19
|
+
],
|
|
20
|
+
"@queues/*": [
|
|
21
|
+
"./src/queues/*"
|
|
22
|
+
],
|
|
23
|
+
"@routes/*": [
|
|
24
|
+
"./src/routes/*"
|
|
25
|
+
],
|
|
26
|
+
"@scripts/*": [
|
|
27
|
+
"./src/scripts/*"
|
|
28
|
+
],
|
|
29
|
+
"@services/*": [
|
|
30
|
+
"./src/services/*"
|
|
31
|
+
],
|
|
32
|
+
"@workers/*": [
|
|
33
|
+
"./src/workers/*"
|
|
34
|
+
],
|
|
35
|
+
"@ws/*": [
|
|
36
|
+
"./src/ws/*"
|
|
37
|
+
],
|
|
38
|
+
"@schemas/*": [
|
|
39
|
+
"./src/schemas/*"
|
|
40
|
+
]
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|