@lastshotlabs/bunshot 0.0.6 → 0.0.8

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.
Files changed (46) hide show
  1. package/dist/adapters/memoryAuth.js +207 -0
  2. package/dist/adapters/mongoAuth.js +93 -0
  3. package/dist/adapters/sqliteAuth.js +242 -0
  4. package/dist/app.js +175 -0
  5. package/dist/cli.js +1 -1
  6. package/dist/index.js +37 -27
  7. package/dist/lib/HttpError.js +7 -0
  8. package/dist/lib/appConfig.js +17 -0
  9. package/dist/lib/authAdapter.js +7 -0
  10. package/dist/lib/authRateLimit.js +77 -0
  11. package/dist/lib/constants.js +2 -0
  12. package/dist/lib/context.js +8 -0
  13. package/dist/lib/emailVerification.js +77 -0
  14. package/dist/lib/fingerprint.js +36 -0
  15. package/dist/lib/jwt.js +11 -0
  16. package/dist/lib/logger.js +7 -0
  17. package/dist/lib/mongo.js +73 -0
  18. package/dist/lib/oauth.js +82 -0
  19. package/dist/lib/queue.js +4 -0
  20. package/dist/lib/redis.js +50 -0
  21. package/dist/lib/roles.js +22 -0
  22. package/dist/lib/session.js +68 -0
  23. package/dist/lib/validate.js +14 -0
  24. package/dist/lib/ws.js +64 -0
  25. package/dist/middleware/bearerAuth.js +10 -0
  26. package/dist/middleware/botProtection.js +50 -0
  27. package/dist/middleware/cacheResponse.js +158 -0
  28. package/dist/middleware/cors.js +17 -0
  29. package/dist/middleware/errorHandler.js +13 -0
  30. package/dist/middleware/identify.js +33 -0
  31. package/dist/middleware/index.js +1 -0
  32. package/dist/middleware/logger.js +7 -0
  33. package/dist/middleware/rateLimit.js +20 -0
  34. package/dist/middleware/requireRole.js +36 -0
  35. package/dist/middleware/requireVerifiedEmail.js +25 -0
  36. package/dist/middleware/userAuth.js +6 -0
  37. package/dist/models/AuthUser.js +14 -0
  38. package/dist/routes/auth.js +207 -0
  39. package/dist/routes/health.js +22 -0
  40. package/dist/routes/home.js +16 -0
  41. package/dist/routes/oauth.js +150 -0
  42. package/dist/schemas/auth.js +9 -0
  43. package/dist/server.js +53 -0
  44. package/dist/services/auth.js +54 -0
  45. package/dist/ws/index.js +31 -0
  46. package/package.json +2 -2
@@ -0,0 +1,7 @@
1
+ export const logger = async (req, next) => {
2
+ const start = performance.now();
3
+ const res = await next(req);
4
+ const ms = (performance.now() - start).toFixed(2);
5
+ console.log(`${req.method} ${new URL(req.url).pathname} ${res.status} ${ms}ms`);
6
+ return res;
7
+ };
@@ -0,0 +1,20 @@
1
+ import { trackAttempt } from "../lib/authRateLimit";
2
+ import { buildFingerprint } from "../lib/fingerprint";
3
+ export const rateLimit = ({ windowMs, max, fingerprintLimit = false, }) => {
4
+ const opts = { windowMs, max };
5
+ return async (c, next) => {
6
+ // Take the leftmost (client) IP from x-forwarded-for
7
+ const raw = c.req.header("x-forwarded-for") ?? "";
8
+ const ip = raw.split(",")[0]?.trim() || "unknown";
9
+ if (await trackAttempt(`ip:${ip}`, opts)) {
10
+ return c.json({ error: "Too Many Requests" }, 429);
11
+ }
12
+ if (fingerprintLimit) {
13
+ const fp = await buildFingerprint(c.req.raw);
14
+ if (await trackAttempt(`fp:${fp}`, opts)) {
15
+ return c.json({ error: "Too Many Requests" }, 429);
16
+ }
17
+ }
18
+ await next();
19
+ };
20
+ };
@@ -0,0 +1,36 @@
1
+ import { getAuthAdapter } from "../lib/authAdapter";
2
+ /**
3
+ * Middleware factory that enforces role-based access.
4
+ * Requires `identify` to have run first (authUserId must be set).
5
+ * Roles are fetched lazily on the first role-checked route and cached on the context.
6
+ *
7
+ * The adapter must implement `getRoles` for this to work.
8
+ *
9
+ * @example
10
+ * // Allow any authenticated user with the "admin" role
11
+ * app.get("/admin", userAuth, requireRole("admin"), handler)
12
+ *
13
+ * // Allow users with either "admin" or "moderator"
14
+ * app.get("/mod", userAuth, requireRole("admin", "moderator"), handler)
15
+ */
16
+ export const requireRole = (...roles) => async (c, next) => {
17
+ const userId = c.get("authUserId");
18
+ if (!userId) {
19
+ return c.json({ error: "Unauthorized" }, 401);
20
+ }
21
+ // Lazy-fetch roles and cache on context so multiple requireRole calls in a chain only hit the adapter once
22
+ let userRoles = c.get("roles");
23
+ if (userRoles === null) {
24
+ const adapter = getAuthAdapter();
25
+ if (!adapter.getRoles) {
26
+ throw new Error("requireRole used but auth adapter does not implement getRoles");
27
+ }
28
+ userRoles = await adapter.getRoles(userId);
29
+ c.set("roles", userRoles);
30
+ }
31
+ const hasRole = roles.some((role) => userRoles.includes(role));
32
+ if (!hasRole) {
33
+ return c.json({ error: "Forbidden" }, 403);
34
+ }
35
+ await next();
36
+ };
@@ -0,0 +1,25 @@
1
+ import { getAuthAdapter } from "../lib/authAdapter";
2
+ /**
3
+ * Middleware that blocks access for users whose email address has not been verified.
4
+ * Must run after `userAuth` (requires `authUserId` to be set on context).
5
+ *
6
+ * The adapter must implement `getEmailVerified` for this to work.
7
+ *
8
+ * @example
9
+ * router.use("/dashboard", userAuth, requireVerifiedEmail);
10
+ */
11
+ export const requireVerifiedEmail = async (c, next) => {
12
+ const userId = c.get("authUserId");
13
+ if (!userId) {
14
+ return c.json({ error: "Unauthorized" }, 401);
15
+ }
16
+ const adapter = getAuthAdapter();
17
+ if (!adapter.getEmailVerified) {
18
+ throw new Error("requireVerifiedEmail used but auth adapter does not implement getEmailVerified");
19
+ }
20
+ const verified = await adapter.getEmailVerified(userId);
21
+ if (!verified) {
22
+ return c.json({ error: "Email not verified" }, 403);
23
+ }
24
+ await next();
25
+ };
@@ -0,0 +1,6 @@
1
+ export const userAuth = async (c, next) => {
2
+ if (!c.get("authUserId")) {
3
+ return c.json({ error: "Unauthorized" }, 401);
4
+ }
5
+ await next();
6
+ };
@@ -0,0 +1,14 @@
1
+ import mongoose from "mongoose";
2
+ import { authConnection } from "../lib/mongo";
3
+ const AuthUserSchema = new mongoose.Schema({
4
+ email: { type: String, unique: true, sparse: true, lowercase: true },
5
+ password: { type: String },
6
+ /** Compound provider keys: ["google:123456", "apple:000111"] */
7
+ providerIds: [{ type: String }],
8
+ /** App-defined roles assigned to this user: ["admin", "editor", ...] */
9
+ roles: [{ type: String }],
10
+ /** Whether the user's email address has been verified. */
11
+ emailVerified: { type: Boolean, default: false },
12
+ }, { timestamps: true });
13
+ AuthUserSchema.index({ providerIds: 1 });
14
+ export const AuthUser = authConnection.model("AuthUser", AuthUserSchema);
@@ -0,0 +1,207 @@
1
+ import { createRoute } from "@hono/zod-openapi";
2
+ import { z } from "zod";
3
+ import { setCookie, getCookie, deleteCookie } from "hono/cookie";
4
+ import * as AuthService from "../services/auth";
5
+ import { makeRegisterSchema, makeLoginSchema } from "../schemas/auth";
6
+ import { COOKIE_TOKEN, HEADER_USER_TOKEN } from "../lib/constants";
7
+ import { userAuth } from "../middleware/userAuth";
8
+ import { isLimited, trackAttempt, bustAuthLimit } from "../lib/authRateLimit";
9
+ import { getAuthAdapter } from "../lib/authAdapter";
10
+ import { createRouter } from "../lib/context";
11
+ import { getVerificationToken, deleteVerificationToken, createVerificationToken } from "../lib/emailVerification";
12
+ const isProd = process.env.NODE_ENV === "production";
13
+ const TokenResponse = z.object({ token: z.string(), emailVerified: z.boolean().optional() });
14
+ const ErrorResponse = z.object({ error: z.string() });
15
+ const tags = ["Auth"];
16
+ const cookieOptions = {
17
+ httpOnly: true,
18
+ secure: isProd,
19
+ sameSite: "Lax",
20
+ path: "/",
21
+ maxAge: 60 * 60 * 24 * 7, // 7 days
22
+ };
23
+ export const createAuthRouter = ({ primaryField, emailVerification, rateLimit }) => {
24
+ const router = createRouter();
25
+ const RegisterSchema = makeRegisterSchema(primaryField);
26
+ const LoginSchema = makeLoginSchema(primaryField);
27
+ const fieldLabel = primaryField.charAt(0).toUpperCase() + primaryField.slice(1);
28
+ const alreadyRegisteredMsg = `${fieldLabel} already registered`;
29
+ // Resolve limits with defaults
30
+ const loginOpts = { windowMs: rateLimit?.login?.windowMs ?? 15 * 60 * 1000, max: rateLimit?.login?.max ?? 10 };
31
+ const registerOpts = { windowMs: rateLimit?.register?.windowMs ?? 60 * 60 * 1000, max: rateLimit?.register?.max ?? 5 };
32
+ const verifyOpts = { windowMs: rateLimit?.verifyEmail?.windowMs ?? 15 * 60 * 1000, max: rateLimit?.verifyEmail?.max ?? 10 };
33
+ const resendOpts = { windowMs: rateLimit?.resendVerification?.windowMs ?? 60 * 60 * 1000, max: rateLimit?.resendVerification?.max ?? 3 };
34
+ router.openapi(createRoute({
35
+ method: "post",
36
+ path: "/auth/register",
37
+ tags,
38
+ request: { body: { content: { "application/json": { schema: RegisterSchema } } } },
39
+ responses: {
40
+ 201: { content: { "application/json": { schema: TokenResponse } }, description: "Registered" },
41
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error" },
42
+ 409: { content: { "application/json": { schema: ErrorResponse } }, description: alreadyRegisteredMsg },
43
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
44
+ },
45
+ }), async (c) => {
46
+ const ip = c.req.header("x-forwarded-for") ?? "unknown";
47
+ if (await trackAttempt(`register:${ip}`, registerOpts)) {
48
+ return c.json({ error: "Too many registration attempts. Try again later." }, 429);
49
+ }
50
+ const body = c.req.valid("json");
51
+ const identifier = body[primaryField];
52
+ const token = await AuthService.register(identifier, body.password);
53
+ setCookie(c, COOKIE_TOKEN, token, cookieOptions);
54
+ return c.json({ token }, 201);
55
+ });
56
+ router.openapi(createRoute({
57
+ method: "post",
58
+ path: "/auth/login",
59
+ tags,
60
+ request: { body: { content: { "application/json": { schema: LoginSchema } } } },
61
+ responses: {
62
+ 200: { content: { "application/json": { schema: TokenResponse } }, description: "Logged in" },
63
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid credentials" },
64
+ 403: { content: { "application/json": { schema: ErrorResponse } }, description: "Email not verified" },
65
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
66
+ },
67
+ }), async (c) => {
68
+ const body = c.req.valid("json");
69
+ const identifier = body[primaryField];
70
+ const limitKey = `login:${identifier}`;
71
+ if (await isLimited(limitKey, loginOpts)) {
72
+ return c.json({ error: "Too many failed login attempts. Try again later." }, 429);
73
+ }
74
+ try {
75
+ const result = await AuthService.login(identifier, body.password);
76
+ await bustAuthLimit(limitKey); // success — clear failure count
77
+ setCookie(c, COOKIE_TOKEN, result.token, cookieOptions);
78
+ return c.json(result, 200);
79
+ }
80
+ catch (err) {
81
+ await trackAttempt(limitKey, loginOpts); // failure — count it
82
+ throw err;
83
+ }
84
+ });
85
+ router.use("/auth/me", userAuth);
86
+ router.openapi(createRoute({
87
+ method: "get",
88
+ path: "/auth/me",
89
+ tags,
90
+ responses: {
91
+ 200: {
92
+ content: {
93
+ "application/json": {
94
+ schema: z.object({
95
+ userId: z.string(),
96
+ email: z.string().optional(),
97
+ emailVerified: z.boolean().optional(),
98
+ googleLinked: z.boolean().optional(),
99
+ }),
100
+ },
101
+ },
102
+ description: "Current user",
103
+ },
104
+ 401: { content: { "application/json": { schema: ErrorResponse } }, description: "Unauthorized" },
105
+ },
106
+ }), async (c) => {
107
+ const authUserId = c.get("authUserId");
108
+ const adapter = getAuthAdapter();
109
+ const user = adapter.getUser ? await adapter.getUser(authUserId) : null;
110
+ const googleLinked = user?.providerIds?.some((id) => id.startsWith("google:")) ?? false;
111
+ return c.json({ userId: authUserId, email: user?.email, emailVerified: user?.emailVerified, googleLinked }, 200);
112
+ });
113
+ router.use("/auth/set-password", userAuth);
114
+ router.openapi(createRoute({
115
+ method: "post",
116
+ path: "/auth/set-password",
117
+ tags,
118
+ request: { body: { content: { "application/json": { schema: z.object({ password: z.string().min(8) }) } } } },
119
+ responses: {
120
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Password set" },
121
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Validation error" },
122
+ 501: { content: { "application/json": { schema: ErrorResponse } }, description: "Not supported by adapter" },
123
+ },
124
+ }), async (c) => {
125
+ const adapter = getAuthAdapter();
126
+ if (!adapter.setPassword) {
127
+ return c.json({ error: "Auth adapter does not support setPassword" }, 501);
128
+ }
129
+ const { password } = c.req.valid("json");
130
+ const authUserId = c.get("authUserId");
131
+ const passwordHash = await Bun.password.hash(password);
132
+ await adapter.setPassword(authUserId, passwordHash);
133
+ return c.json({ message: "Password updated" }, 200);
134
+ });
135
+ router.openapi(createRoute({
136
+ method: "post",
137
+ path: "/auth/logout",
138
+ tags,
139
+ responses: {
140
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Logged out" },
141
+ },
142
+ }), async (c) => {
143
+ const token = getCookie(c, COOKIE_TOKEN) ?? c.req.header(HEADER_USER_TOKEN) ?? null;
144
+ await AuthService.logout(token);
145
+ deleteCookie(c, COOKIE_TOKEN, { path: "/" });
146
+ return c.json({ message: "Logged out" }, 200);
147
+ });
148
+ // Email verification routes — only mounted when emailVerification is configured and primaryField is "email"
149
+ if (emailVerification && primaryField === "email") {
150
+ router.openapi(createRoute({
151
+ method: "post",
152
+ path: "/auth/verify-email",
153
+ tags,
154
+ request: { body: { content: { "application/json": { schema: z.object({ token: z.string() }) } } } },
155
+ responses: {
156
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Email verified" },
157
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Invalid or expired token" },
158
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
159
+ },
160
+ }), async (c) => {
161
+ const ip = c.req.header("x-forwarded-for") ?? "unknown";
162
+ if (await trackAttempt(`verify:${ip}`, verifyOpts)) {
163
+ return c.json({ error: "Too many verification attempts. Try again later." }, 429);
164
+ }
165
+ const { token } = c.req.valid("json");
166
+ const adapter = getAuthAdapter();
167
+ const entry = await getVerificationToken(token);
168
+ if (!entry)
169
+ return c.json({ error: "Invalid or expired verification token" }, 400);
170
+ if (adapter.setEmailVerified)
171
+ await adapter.setEmailVerified(entry.userId, true);
172
+ await deleteVerificationToken(token);
173
+ return c.json({ message: "Email verified" }, 200);
174
+ });
175
+ router.use("/auth/resend-verification", userAuth);
176
+ router.openapi(createRoute({
177
+ method: "post",
178
+ path: "/auth/resend-verification",
179
+ tags,
180
+ responses: {
181
+ 200: { content: { "application/json": { schema: z.object({ message: z.string() }) } }, description: "Verification email sent" },
182
+ 400: { content: { "application/json": { schema: ErrorResponse } }, description: "Already verified" },
183
+ 429: { content: { "application/json": { schema: ErrorResponse } }, description: "Too many attempts" },
184
+ 501: { content: { "application/json": { schema: ErrorResponse } }, description: "Not supported by adapter" },
185
+ },
186
+ }), async (c) => {
187
+ const adapter = getAuthAdapter();
188
+ if (!adapter.getEmailVerified || !adapter.getUser) {
189
+ return c.json({ error: "Auth adapter does not support email verification" }, 501);
190
+ }
191
+ const authUserId = c.get("authUserId");
192
+ if (await trackAttempt(`resend:${authUserId}`, resendOpts)) {
193
+ return c.json({ error: "Too many resend attempts. Try again later." }, 429);
194
+ }
195
+ const alreadyVerified = await adapter.getEmailVerified(authUserId);
196
+ if (alreadyVerified)
197
+ return c.json({ error: "Email already verified" }, 400);
198
+ const user = await adapter.getUser(authUserId);
199
+ if (!user?.email)
200
+ return c.json({ error: "No email address on file" }, 400);
201
+ const verificationToken = await createVerificationToken(authUserId, user.email);
202
+ await emailVerification.onSend(user.email, verificationToken);
203
+ return c.json({ message: "Verification email sent" }, 200);
204
+ });
205
+ }
206
+ return router;
207
+ };
@@ -0,0 +1,22 @@
1
+ import { createRoute } from "@hono/zod-openapi";
2
+ import { z } from "zod";
3
+ import { createRouter } from "../lib/context";
4
+ export const router = createRouter();
5
+ router.openapi(createRoute({
6
+ method: "get",
7
+ path: "/health",
8
+ tags: ["Core"],
9
+ responses: {
10
+ 200: {
11
+ content: {
12
+ "application/json": {
13
+ schema: z.object({
14
+ status: z.enum(["ok"]),
15
+ timestamp: z.string(),
16
+ }),
17
+ },
18
+ },
19
+ description: "Service health check",
20
+ },
21
+ },
22
+ }), (c) => c.json({ status: "ok", timestamp: new Date().toISOString() }));
@@ -0,0 +1,16 @@
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
+ export const router = createRouter();
6
+ router.openapi(createRoute({
7
+ method: "get",
8
+ path: "/",
9
+ tags: ["Core"],
10
+ responses: {
11
+ 200: {
12
+ content: { "application/json": { schema: z.object({ message: z.string() }) } },
13
+ description: "API is running",
14
+ },
15
+ },
16
+ }), (c) => c.json({ message: `${getAppName()} is running` }));
@@ -0,0 +1,150 @@
1
+ import { createRouter } from "../lib/context";
2
+ import { setCookie } from "hono/cookie";
3
+ import { decodeIdToken } from "arctic";
4
+ import { getGoogle, getApple, storeOAuthState, consumeOAuthState, generateState, generateCodeVerifier, } from "../lib/oauth";
5
+ import { getAuthAdapter } from "../lib/authAdapter";
6
+ import { HttpError } from "../lib/HttpError";
7
+ import { signToken } from "../lib/jwt";
8
+ import { createSession } from "../lib/session";
9
+ import { COOKIE_TOKEN } from "../lib/constants";
10
+ import { userAuth } from "../middleware/userAuth";
11
+ import { getDefaultRole } from "../lib/appConfig";
12
+ const isProd = process.env.NODE_ENV === "production";
13
+ const cookieOptions = {
14
+ httpOnly: true,
15
+ secure: isProd,
16
+ sameSite: "Lax",
17
+ path: "/",
18
+ maxAge: 60 * 60 * 24 * 7,
19
+ };
20
+ const finishOAuth = async (c, provider, providerId, profile, postLoginRedirect) => {
21
+ const adapter = getAuthAdapter();
22
+ if (!adapter.findOrCreateByProvider) {
23
+ return c.json({ error: "Auth adapter does not support social login" }, 500);
24
+ }
25
+ let user;
26
+ try {
27
+ user = await adapter.findOrCreateByProvider(provider, providerId, profile);
28
+ }
29
+ catch (err) {
30
+ const message = err instanceof HttpError ? err.message : "Authentication failed";
31
+ const sep = postLoginRedirect.includes("?") ? "&" : "?";
32
+ return c.redirect(`${postLoginRedirect}${sep}error=${encodeURIComponent(message)}`);
33
+ }
34
+ if (user.created) {
35
+ const role = getDefaultRole();
36
+ if (role && adapter.setRoles)
37
+ await adapter.setRoles(user.id, [role]);
38
+ }
39
+ const token = await signToken(user.id);
40
+ await createSession(user.id, token);
41
+ setCookie(c, COOKIE_TOKEN, token, cookieOptions);
42
+ // Append token to redirect so non-browser clients (mobile deep links) can extract it.
43
+ // Browser apps can safely ignore the query param.
44
+ try {
45
+ const url = new URL(postLoginRedirect);
46
+ url.searchParams.set("token", token);
47
+ if (profile.email)
48
+ url.searchParams.set("user", profile.email);
49
+ return c.redirect(url.toString());
50
+ }
51
+ catch {
52
+ // Relative path fallback
53
+ const sep = postLoginRedirect.includes("?") ? "&" : "?";
54
+ const userParam = profile.email ? `&user=${encodeURIComponent(profile.email)}` : "";
55
+ return c.redirect(`${postLoginRedirect}${sep}token=${token}${userParam}`);
56
+ }
57
+ };
58
+ export const createOAuthRouter = (providers, postLoginRedirect) => {
59
+ const router = createRouter();
60
+ // ─── Google ───────────────────────────────────────────────────────────────
61
+ if (providers.includes("google")) {
62
+ router.get("/auth/google", async (c) => {
63
+ const state = generateState();
64
+ const codeVerifier = generateCodeVerifier();
65
+ await storeOAuthState(state, codeVerifier);
66
+ const url = getGoogle().createAuthorizationURL(state, codeVerifier, ["openid", "profile", "email"]);
67
+ return c.redirect(url.toString());
68
+ });
69
+ router.get("/auth/google/callback", async (c) => {
70
+ const { code, state } = c.req.query();
71
+ if (!code || !state)
72
+ return c.json({ error: "Invalid callback" }, 400);
73
+ const stored = await consumeOAuthState(state);
74
+ if (!stored?.codeVerifier)
75
+ return c.json({ error: "Invalid or expired state" }, 400);
76
+ const tokens = await getGoogle().validateAuthorizationCode(code, stored.codeVerifier);
77
+ const info = await fetch("https://openidconnect.googleapis.com/v1/userinfo", {
78
+ headers: { Authorization: `Bearer ${tokens.accessToken()}` },
79
+ }).then((r) => r.json());
80
+ if (stored.linkUserId) {
81
+ const adapter = getAuthAdapter();
82
+ if (!adapter.linkProvider)
83
+ return c.json({ error: "Auth adapter does not support linkProvider" }, 500);
84
+ await adapter.linkProvider(stored.linkUserId, "google", info.sub);
85
+ const sep = postLoginRedirect.includes("?") ? "&" : "?";
86
+ return c.redirect(`${postLoginRedirect}${sep}linked=google`);
87
+ }
88
+ return finishOAuth(c, "google", info.sub, { email: info.email, name: info.name, avatarUrl: info.picture }, postLoginRedirect);
89
+ });
90
+ router.get("/auth/google/link", userAuth, async (c) => {
91
+ const state = generateState();
92
+ const codeVerifier = generateCodeVerifier();
93
+ await storeOAuthState(state, codeVerifier, c.get("authUserId"));
94
+ const url = getGoogle().createAuthorizationURL(state, codeVerifier, ["openid", "profile", "email"]);
95
+ return c.redirect(url.toString());
96
+ });
97
+ router.delete("/auth/google/link", userAuth, async (c) => {
98
+ const adapter = getAuthAdapter();
99
+ if (!adapter.unlinkProvider) {
100
+ return c.json({ error: "Auth adapter does not support unlinkProvider" }, 500);
101
+ }
102
+ await adapter.unlinkProvider(c.get("authUserId"), "google");
103
+ return c.body(null, 204);
104
+ });
105
+ }
106
+ // ─── Apple ────────────────────────────────────────────────────────────────
107
+ if (providers.includes("apple")) {
108
+ router.get("/auth/apple", async (c) => {
109
+ const state = generateState();
110
+ await storeOAuthState(state);
111
+ const url = getApple().createAuthorizationURL(state, ["name", "email"]);
112
+ return c.redirect(url.toString());
113
+ });
114
+ // Apple sends a POST with form data to the callback URL
115
+ router.post("/auth/apple/callback", async (c) => {
116
+ const form = await c.req.formData();
117
+ const code = form.get("code");
118
+ const state = form.get("state");
119
+ if (!code || !state)
120
+ return c.json({ error: "Invalid callback" }, 400);
121
+ const stored = await consumeOAuthState(state);
122
+ if (!stored)
123
+ return c.json({ error: "Invalid or expired state" }, 400);
124
+ const tokens = await getApple().validateAuthorizationCode(code);
125
+ const claims = decodeIdToken(tokens.idToken());
126
+ if (stored.linkUserId) {
127
+ const adapter = getAuthAdapter();
128
+ if (!adapter.linkProvider)
129
+ return c.json({ error: "Auth adapter does not support linkProvider" }, 500);
130
+ await adapter.linkProvider(stored.linkUserId, "apple", claims.sub);
131
+ const sep = postLoginRedirect.includes("?") ? "&" : "?";
132
+ return c.redirect(`${postLoginRedirect}${sep}linked=apple`);
133
+ }
134
+ // Apple only sends name on the very first sign-in
135
+ const userJSON = form.get("user");
136
+ const userInfo = userJSON ? JSON.parse(userJSON) : {};
137
+ const name = userInfo.name
138
+ ? `${userInfo.name.firstName ?? ""} ${userInfo.name.lastName ?? ""}`.trim() || undefined
139
+ : undefined;
140
+ return finishOAuth(c, "apple", claims.sub, { email: claims.email, name }, postLoginRedirect);
141
+ });
142
+ router.get("/auth/apple/link", userAuth, async (c) => {
143
+ const state = generateState();
144
+ await storeOAuthState(state, undefined, c.get("authUserId"));
145
+ const url = getApple().createAuthorizationURL(state, ["name", "email"]);
146
+ return c.redirect(url.toString());
147
+ });
148
+ }
149
+ return router;
150
+ };
@@ -0,0 +1,9 @@
1
+ import { z } from "zod";
2
+ export const makeRegisterSchema = (primaryField) => z.object({
3
+ [primaryField]: primaryField === "email" ? z.string().email() : z.string().min(3),
4
+ password: z.string().min(8),
5
+ });
6
+ export const makeLoginSchema = (primaryField) => z.object({
7
+ [primaryField]: primaryField === "email" ? z.string().email() : z.string().min(1),
8
+ password: z.string().min(1),
9
+ });
package/dist/server.js ADDED
@@ -0,0 +1,53 @@
1
+ import { createApp } from "./app";
2
+ import { websocket as defaultWebsocket, createWsUpgradeHandler } from "./ws/index";
3
+ import { setWsServer, handleRoomActions, cleanupSocket } from "./lib/ws";
4
+ import { log } from "./lib/logger";
5
+ export const createServer = async (config) => {
6
+ const app = await createApp(config);
7
+ const port = Number(process.env.PORT ?? config.port ?? 3000);
8
+ const { workersDir, enableWorkers = true, ws: wsConfig = {} } = config;
9
+ const { handler: userWs, upgradeHandler: wsUpgradeHandler, onRoomSubscribe } = wsConfig;
10
+ const defaultOpen = defaultWebsocket.open;
11
+ const defaultMessage = defaultWebsocket.message;
12
+ const defaultClose = defaultWebsocket.close;
13
+ const defaultDrain = defaultWebsocket.drain;
14
+ const ws = {
15
+ open: userWs?.open ?? defaultOpen,
16
+ async message(socket, message) {
17
+ if (!await handleRoomActions(socket, message, onRoomSubscribe)) {
18
+ (userWs?.message ?? defaultMessage)(socket, message);
19
+ }
20
+ },
21
+ close(socket, code, reason) {
22
+ cleanupSocket(socket.data.id, socket.data.rooms);
23
+ socket.data.rooms.clear();
24
+ (userWs?.close ?? defaultClose)(socket, code, reason);
25
+ },
26
+ drain: userWs?.drain ?? defaultDrain,
27
+ };
28
+ let server;
29
+ server = Bun.serve({
30
+ port,
31
+ routes: {
32
+ "/ws": (req) => wsUpgradeHandler
33
+ ? wsUpgradeHandler(req, server)
34
+ : createWsUpgradeHandler(server)(req),
35
+ },
36
+ fetch: app.fetch,
37
+ websocket: ws,
38
+ error(err) {
39
+ console.error(err);
40
+ return Response.json({ error: "Internal Server Error" }, { status: 500 });
41
+ },
42
+ });
43
+ setWsServer(server);
44
+ if (enableWorkers && workersDir) {
45
+ const glob = new Bun.Glob("**/*.ts");
46
+ for await (const file of glob.scan({ cwd: workersDir })) {
47
+ await import(`${workersDir}/${file}`);
48
+ }
49
+ }
50
+ log(`[server] running at http://localhost:${server.port}`);
51
+ log(`[server] API docs at http://localhost:${server.port}/docs`);
52
+ return server;
53
+ };
@@ -0,0 +1,54 @@
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
+ export const register = async (identifier, password) => {
8
+ const hashed = await Bun.password.hash(password);
9
+ const adapter = getAuthAdapter();
10
+ const user = await adapter.create(identifier, hashed);
11
+ const role = getDefaultRole();
12
+ if (role)
13
+ await adapter.setRoles(user.id, [role]);
14
+ const token = await signToken(user.id);
15
+ await createSession(user.id, token);
16
+ const evConfig = getEmailVerificationConfig();
17
+ if (evConfig && getPrimaryField() === "email") {
18
+ try {
19
+ const verificationToken = await createVerificationToken(user.id, identifier);
20
+ await evConfig.onSend(identifier, verificationToken);
21
+ }
22
+ catch (e) {
23
+ console.error("[email-verification] Failed to send verification email:", e);
24
+ }
25
+ }
26
+ return token;
27
+ };
28
+ export const login = async (identifier, password) => {
29
+ const adapter = getAuthAdapter();
30
+ const findFn = adapter.findByIdentifier ?? adapter.findByEmail.bind(adapter);
31
+ const user = await findFn(identifier);
32
+ if (!user || !(await Bun.password.verify(password, user.passwordHash))) {
33
+ throw new HttpError(401, "Invalid credentials");
34
+ }
35
+ const evConfig = getEmailVerificationConfig();
36
+ if (evConfig && getPrimaryField() === "email" && adapter.getEmailVerified) {
37
+ const verified = await adapter.getEmailVerified(user.id);
38
+ if (evConfig.required && !verified) {
39
+ throw new HttpError(403, "Email not verified");
40
+ }
41
+ const token = await signToken(user.id);
42
+ await createSession(user.id, token);
43
+ return { token, emailVerified: verified };
44
+ }
45
+ const token = await signToken(user.id);
46
+ await createSession(user.id, token);
47
+ return { token };
48
+ };
49
+ export const logout = async (token) => {
50
+ if (token) {
51
+ const payload = await verifyToken(token);
52
+ await deleteSession(payload.sub);
53
+ }
54
+ };
@@ -0,0 +1,31 @@
1
+ import { verifyToken } from "../lib/jwt";
2
+ import { getSession } from "../lib/session";
3
+ import { COOKIE_TOKEN } from "../lib/constants";
4
+ export const createWsUpgradeHandler = (server) => async (req) => {
5
+ let userId = null;
6
+ try {
7
+ const token = req.headers.get("cookie")
8
+ ?.match(new RegExp(`(?:^|;\\s*)${COOKIE_TOKEN}=([^;]+)`))?.[1] ?? null;
9
+ if (token) {
10
+ const payload = await verifyToken(token);
11
+ const stored = await getSession(payload.sub);
12
+ if (stored === token)
13
+ userId = payload.sub;
14
+ }
15
+ }
16
+ catch { /* unauthenticated — userId stays null */ }
17
+ const upgraded = server.upgrade(req, { data: { id: crypto.randomUUID(), userId, rooms: new Set() } });
18
+ return upgraded ? undefined : Response.json({ error: "Upgrade failed" }, { status: 400 });
19
+ };
20
+ export const websocket = {
21
+ open(ws) {
22
+ console.log(`[ws] connected: ${ws.data.id}`);
23
+ ws.send(JSON.stringify({ event: "connected", id: ws.data.id }));
24
+ },
25
+ message(ws, message) {
26
+ ws.send(message);
27
+ },
28
+ close(ws) {
29
+ console.log(`[ws] disconnected: ${ws.data.id}`);
30
+ },
31
+ };