@nexa-stack/framework 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/.env.example +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +72 -0
  4. package/bin/nexa.mjs +41 -0
  5. package/bin/nexa.ts +334 -0
  6. package/docs/AI.md +69 -0
  7. package/docs/ARCHITECTURE.md +74 -0
  8. package/docs/EXAMPLES.md +114 -0
  9. package/docs/FRAMEWORK.md +226 -0
  10. package/docs/LANGUAGE.md +39 -0
  11. package/docs/README.md +7 -0
  12. package/docs/READY.md +51 -0
  13. package/docs/REFERENCE.md +255 -0
  14. package/docs/START.md +98 -0
  15. package/docs/advanced.md +97 -0
  16. package/docs/authentication.md +54 -0
  17. package/docs/cli.md +15 -0
  18. package/docs/compare.md +51 -0
  19. package/docs/configuration.md +65 -0
  20. package/docs/database.md +396 -0
  21. package/docs/installation.md +57 -0
  22. package/docs/localization.md +47 -0
  23. package/docs/resources.md +75 -0
  24. package/docs/routing.md +59 -0
  25. package/docs/seeding.md +33 -0
  26. package/docs/services.md +146 -0
  27. package/package.json +77 -0
  28. package/packages/auth/src/auth.test.ts +23 -0
  29. package/packages/auth/src/auth.ts +287 -0
  30. package/packages/auth/src/index.ts +17 -0
  31. package/packages/cache/src/index.ts +203 -0
  32. package/packages/client/src/index.ts +49 -0
  33. package/packages/core/src/app.ts +97 -0
  34. package/packages/core/src/config.ts +55 -0
  35. package/packages/core/src/dev.ts +104 -0
  36. package/packages/core/src/fields.test.ts +81 -0
  37. package/packages/core/src/fields.ts +309 -0
  38. package/packages/core/src/index.ts +60 -0
  39. package/packages/core/src/lang.ts +79 -0
  40. package/packages/core/src/loader.ts +42 -0
  41. package/packages/core/src/migrate.ts +91 -0
  42. package/packages/core/src/policy.ts +36 -0
  43. package/packages/core/src/registry.ts +17 -0
  44. package/packages/core/src/reload.ts +92 -0
  45. package/packages/core/src/resource.test.ts +32 -0
  46. package/packages/core/src/resource.ts +87 -0
  47. package/packages/core/src/routes.ts +22 -0
  48. package/packages/core/src/runtime.ts +11 -0
  49. package/packages/database/src/builder.ts +266 -0
  50. package/packages/database/src/database.ts +252 -0
  51. package/packages/database/src/dialect.ts +186 -0
  52. package/packages/database/src/index.ts +6 -0
  53. package/packages/database/src/mysql.ts +114 -0
  54. package/packages/database/src/postgres.ts +117 -0
  55. package/packages/database/src/query.ts +115 -0
  56. package/packages/database/src/sqlite.ts +216 -0
  57. package/packages/database/src/types.ts +104 -0
  58. package/packages/events/src/index.ts +17 -0
  59. package/packages/export/src/index.ts +36 -0
  60. package/packages/log/src/index.ts +38 -0
  61. package/packages/mail/src/index.ts +130 -0
  62. package/packages/notifications/src/index.ts +84 -0
  63. package/packages/plugins/src/index.ts +39 -0
  64. package/packages/queue/src/index.ts +185 -0
  65. package/packages/queue/src/jobs.ts +9 -0
  66. package/packages/schedule/src/index.ts +64 -0
  67. package/packages/server/src/index.ts +1 -0
  68. package/packages/server/src/middleware.ts +143 -0
  69. package/packages/server/src/query.ts +40 -0
  70. package/packages/server/src/router.ts +813 -0
  71. package/packages/sms/src/index.ts +33 -0
  72. package/packages/storage/src/upload.ts +36 -0
  73. package/packages/testing/src/index.ts +67 -0
  74. package/packages/validation/src/index.ts +1 -0
  75. package/packages/validation/src/validate.test.ts +35 -0
  76. package/packages/validation/src/validate.ts +112 -0
  77. package/public/admin.html +369 -0
  78. package/public/compare.html +66 -0
  79. package/public/dev-bar.js +213 -0
  80. package/public/docs.html +315 -0
  81. package/public/index.html +66 -0
@@ -0,0 +1,146 @@
1
+ # Mail · Notifications · Cache · Log · Schedule
2
+
3
+ Built-in services with concise Nexa syntax.
4
+
5
+ ## Mail
6
+
7
+ ```ts
8
+ import { mail, Mail, on, getDb } from "./packages/core/src/index.js";
9
+
10
+ // Simple
11
+ await mail("ali@corp.com", "New invoice", "Invoice #12 created");
12
+
13
+ // HTML
14
+ await Mail().to("ali@corp.com").subject("Hello").html("<b>Hi</b>").send();
15
+
16
+ // Via queue
17
+ await Mail().to("ali@corp.com").subject("Hello").text("Hi").queue(getDb()!);
18
+ // then: bun run nexa queue:work
19
+ ```
20
+
21
+ ### .env
22
+
23
+ ```env
24
+ MAIL_DRIVER=log # default — writes storage/logs/mail.log
25
+ # MAIL_DRIVER=smtp
26
+ # MAIL_HOST=smtp.mailtrap.io
27
+ # MAIL_PORT=587
28
+ # MAIL_USER=...
29
+ # MAIL_PASS=...
30
+ # MAIL_FROM=noreply@yourapp.com
31
+ ```
32
+
33
+ With events:
34
+
35
+ ```ts
36
+ on("orders.created", async ({ data }) => {
37
+ await mail("admin@corp.com", "New order", `Order #${data.id}`);
38
+ });
39
+ ```
40
+
41
+ ---
42
+
43
+ ## Notifications
44
+
45
+ ```ts
46
+ import { notify, on } from "./packages/core/src/index.js";
47
+
48
+ await notify(
49
+ { id: 1, email: "ali@corp.com" },
50
+ {
51
+ title: "New order",
52
+ body: "Your order was created",
53
+ via: ["database", "mail"], // default: ["database"]
54
+ }
55
+ );
56
+ ```
57
+
58
+ API (login required):
59
+
60
+ | Method | URL | Description |
61
+ |--------|-----|-------------|
62
+ | GET | `/api/notifications` | Unread list |
63
+ | POST | `/api/notifications/:id/read` | Mark one read |
64
+ | POST | `/api/notifications/read-all` | Mark all read |
65
+
66
+ ---
67
+
68
+ ## Cache
69
+
70
+ ```ts
71
+ import { cache } from "./packages/core/src/index.js";
72
+
73
+ await cache.set("stats", { total: 10 }, 60); // 60 seconds
74
+ await cache.get("stats");
75
+ await cache.forget("stats");
76
+
77
+ const value = await cache.remember("dashboard", 120, async () => {
78
+ return { revenue: 5000 };
79
+ });
80
+ ```
81
+
82
+ ```env
83
+ CACHE_DRIVER=memory # or file → storage/cache/
84
+ # CACHE_DRIVER=redis
85
+ # REDIS_URL=redis://127.0.0.1:6379
86
+ ```
87
+
88
+ Redis is optional (`ioredis` or Bun redis).
89
+ ---
90
+
91
+ ## Logging
92
+
93
+ ```ts
94
+ import { log } from "./packages/core/src/index.js";
95
+
96
+ log.debug("...");
97
+ log.info("Server started");
98
+ log.warn("Slow query");
99
+ log.error("Failed", { err });
100
+ ```
101
+
102
+ Writes to console + `storage/logs/nexa.log`.
103
+
104
+ ```env
105
+ LOG_LEVEL=info # debug | info | warn | error
106
+ ```
107
+
108
+ ---
109
+
110
+ ## Schedule
111
+
112
+ `schedule.ts` in project root:
113
+
114
+ ```ts
115
+ import { schedule, log, processJobs, getDb } from "./packages/core/src/index.js";
116
+
117
+ schedule.everyMinute(async () => {
118
+ await processJobs(getDb()!, 20);
119
+ });
120
+
121
+ schedule.daily(() => log.info("Daily cleanup"));
122
+ ```
123
+
124
+ ```bash
125
+ bun run nexa schedule:work
126
+ ```
127
+
128
+ | Method | Interval |
129
+ |--------|----------|
130
+ | `everyMinute` | 1 min |
131
+ | `everyFiveMinutes` | 5 min |
132
+ | `everyHour` | 1 hour |
133
+ | `daily` | 24 hours |
134
+ | `every(seconds, fn)` | custom |
135
+
136
+ ---
137
+
138
+ ## vs Laravel
139
+
140
+ | Laravel | Nexa |
141
+ |---------|------|
142
+ | `Mail::to()->send()` | `Mail().to().send()` / `mail()` |
143
+ | `Notification::send()` | `notify(user, {...})` |
144
+ | `Cache::remember()` | `cache.remember()` |
145
+ | `Log::info()` | `log.info()` |
146
+ | `Schedule::` + `schedule:run` | `schedule.` + `schedule:work` |
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "@nexa-stack/framework",
3
+ "version": "1.0.0",
4
+ "description": "Nexa Stack — Node.js framework. One resource → REST API, ORM, auth, queue, and admin panel. Zero native build (Hostinger / Windows friendly).",
5
+ "type": "module",
6
+ "main": "./packages/core/src/index.ts",
7
+ "module": "./packages/core/src/index.ts",
8
+ "types": "./packages/core/src/index.ts",
9
+ "exports": {
10
+ ".": "./packages/core/src/index.ts",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "bin": {
14
+ "nexa": "./bin/nexa.mjs"
15
+ },
16
+ "files": [
17
+ "packages",
18
+ "bin",
19
+ "public",
20
+ "docs",
21
+ "README.md",
22
+ "LICENSE",
23
+ ".env.example"
24
+ ],
25
+ "homepage": "https://www.npmjs.com/package/@nexa-stack/framework",
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "scripts": {
30
+ "dev": "nexa serve",
31
+ "nexa": "node --import tsx bin/nexa.ts",
32
+ "serve": "node --import tsx bin/nexa.ts serve",
33
+ "test": "vitest run",
34
+ "prepublishOnly": "vitest run"
35
+ },
36
+ "keywords": [
37
+ "nexa",
38
+ "nexa-stack",
39
+ "@nexa-stack/framework",
40
+ "framework",
41
+ "node",
42
+ "nodejs",
43
+ "fullstack",
44
+ "api",
45
+ "typescript",
46
+ "admin",
47
+ "crud",
48
+ "orm",
49
+ "rest",
50
+ "hostinger"
51
+ ],
52
+ "license": "MIT",
53
+ "engines": {
54
+ "node": ">=20"
55
+ },
56
+ "dependencies": {
57
+ "bcryptjs": "^3.0.2",
58
+ "mysql2": "^3.24.2",
59
+ "nodemailer": "^9.0.6",
60
+ "postgres": "^3.4.9",
61
+ "sql.js": "^1.14.2",
62
+ "tsx": "^4.20.3"
63
+ },
64
+ "optionalDependencies": {
65
+ "ioredis": "^5.6.1"
66
+ },
67
+ "devDependencies": {
68
+ "@types/bcryptjs": "^2.4.6",
69
+ "@types/node": "^22.15.30",
70
+ "@types/nodemailer": "^8.0.1",
71
+ "@types/sql.js": "^1.4.11",
72
+ "vitest": "^3.2.3"
73
+ },
74
+ "allowScripts": {
75
+ "esbuild@0.28.2": true
76
+ }
77
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test, beforeAll } from "vitest";
2
+ import { signToken, verifyToken, hasRole, setupAuth } from "./auth.js";
3
+ import { createDatabase } from "../../database/src/database.js";
4
+
5
+ describe("auth", () => {
6
+ beforeAll(async () => {
7
+ const db = createDatabase(":memory:");
8
+ await db.connect();
9
+ await setupAuth({ secret: "auth-test-secret-key" }, db);
10
+ });
11
+
12
+ test("sign and verify token", () => {
13
+ const user = { id: 1, email: "a@test.com", role: "user" };
14
+ const token = signToken(user);
15
+ const decoded = verifyToken(token);
16
+ expect(decoded?.email).toBe("a@test.com");
17
+ });
18
+
19
+ test("hasRole admin bypass", () => {
20
+ expect(hasRole({ id: 1, email: "a", role: "admin" }, "user")).toBe(true);
21
+ expect(hasRole({ id: 1, email: "a", role: "user" }, "admin")).toBe(false);
22
+ });
23
+ });
@@ -0,0 +1,287 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+ import type { Database } from "../../database/src/database.js";
3
+ import { passwordResetsTableSql, usersTableSql } from "../../database/src/dialect.js";
4
+ import { mail } from "../../mail/src/index.js";
5
+ import { config as appConfig } from "../../core/src/config.js";
6
+
7
+ export interface AuthUser {
8
+ id: number;
9
+ email: string;
10
+ role: string;
11
+ email_verified_at?: string | null;
12
+ }
13
+
14
+ export interface AuthConfig {
15
+ secret: string;
16
+ }
17
+
18
+ let authConfig: AuthConfig = { secret: "change-me" };
19
+ let dbRef: Database | null = null;
20
+
21
+ export async function setupAuth(cfg: AuthConfig, db: Database): Promise<void> {
22
+ if (!cfg.secret || cfg.secret.length < 8) {
23
+ throw new Error("APP_SECRET must be at least 8 characters");
24
+ }
25
+ authConfig = cfg;
26
+ dbRef = db;
27
+ await db.run(usersTableSql(db.dialect));
28
+ await db.run(passwordResetsTableSql(db.dialect));
29
+ await ensureUserColumns(db);
30
+ }
31
+
32
+ async function ensureUserColumns(db: Database): Promise<void> {
33
+ const alters = ["ALTER TABLE users ADD COLUMN email_verified_at TEXT"];
34
+ for (const sql of alters) {
35
+ try {
36
+ await db.run(sql);
37
+ } catch {
38
+ /* column already exists */
39
+ }
40
+ }
41
+ }
42
+
43
+ import bcrypt from "bcryptjs";
44
+
45
+ export async function hashPassword(password: string): Promise<string> {
46
+ return bcrypt.hash(password, 10);
47
+ }
48
+
49
+ export async function verifyPassword(password: string, hash: string): Promise<boolean> {
50
+ return bcrypt.compare(password, hash);
51
+ }
52
+
53
+ function b64urlJson(data: unknown): string {
54
+ return Buffer.from(JSON.stringify(data), "utf8")
55
+ .toString("base64")
56
+ .replace(/\+/g, "-")
57
+ .replace(/\//g, "_")
58
+ .replace(/=+$/, "");
59
+ }
60
+
61
+ function b64urlBytes(buf: Buffer): string {
62
+ return buf
63
+ .toString("base64")
64
+ .replace(/\+/g, "-")
65
+ .replace(/\//g, "_")
66
+ .replace(/=+$/, "");
67
+ }
68
+
69
+ function b64urlDecode(s: string): string {
70
+ const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
71
+ return Buffer.from(s.replace(/-/g, "+").replace(/_/g, "/") + pad, "base64").toString("utf8");
72
+ }
73
+
74
+ function hmacSign(header: string, payload: string): string {
75
+ return b64urlBytes(
76
+ createHmac("sha256", authConfig.secret).update(`${header}.${payload}`).digest()
77
+ );
78
+ }
79
+
80
+ function safeEqual(a: string, b: string): boolean {
81
+ const ba = Buffer.from(a);
82
+ const bb = Buffer.from(b);
83
+ if (ba.length !== bb.length) return false;
84
+ return timingSafeEqual(ba, bb);
85
+ }
86
+
87
+ function randomToken(): string {
88
+ return crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
89
+ }
90
+
91
+ export function signToken(user: AuthUser): string {
92
+ const header = b64urlJson({ alg: "HS256", typ: "JWT" });
93
+ const payload = b64urlJson({
94
+ sub: user.id,
95
+ email: user.email,
96
+ role: user.role,
97
+ exp: Date.now() + 86400000,
98
+ });
99
+ const sig = hmacSign(header, payload);
100
+ return `${header}.${payload}.${sig}`;
101
+ }
102
+
103
+ export function verifyToken(token: string): AuthUser | null {
104
+ try {
105
+ const parts = token.split(".");
106
+ if (parts.length !== 3) return null;
107
+ const [headerB64, payloadB64, sig] = parts;
108
+
109
+ const header = JSON.parse(b64urlDecode(headerB64));
110
+ if (header.alg !== "HS256") return null;
111
+
112
+ const expected = hmacSign(headerB64, payloadB64);
113
+ if (!safeEqual(sig, expected)) return null;
114
+
115
+ const payload = JSON.parse(b64urlDecode(payloadB64));
116
+ if (typeof payload.exp !== "number" || payload.exp < Date.now()) return null;
117
+ if (payload.sub == null || !payload.email || !payload.role) return null;
118
+
119
+ return {
120
+ id: Number(payload.sub),
121
+ email: String(payload.email),
122
+ role: String(payload.role),
123
+ };
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ export function userFromRequest(req: Request): AuthUser | null {
130
+ const header = req.headers.get("authorization");
131
+ if (!header?.startsWith("Bearer ")) return null;
132
+ return verifyToken(header.slice(7));
133
+ }
134
+
135
+ function toUser(row: Record<string, unknown>): AuthUser {
136
+ return {
137
+ id: row.id as number,
138
+ email: row.email as string,
139
+ role: row.role as string,
140
+ email_verified_at: (row.email_verified_at as string) ?? null,
141
+ };
142
+ }
143
+
144
+ export async function register(
145
+ email: string,
146
+ password: string,
147
+ role = "user"
148
+ ): Promise<AuthUser> {
149
+ if (!dbRef) throw new Error("Call auth() first");
150
+ const hash = await hashPassword(password);
151
+ const row = await dbRef.insert("users", {
152
+ email,
153
+ password: hash,
154
+ role,
155
+ email_verified_at: null,
156
+ });
157
+ const user = toUser(row);
158
+ await sendVerificationEmail(user).catch(() => undefined);
159
+ return user;
160
+ }
161
+
162
+ export async function login(
163
+ email: string,
164
+ password: string
165
+ ): Promise<{ user: AuthUser; token: string } | null> {
166
+ if (!dbRef) throw new Error("Call start() first");
167
+ const row = await dbRef.getOne("SELECT * FROM users WHERE email = ?", [email]);
168
+ if (!row) return null;
169
+ const ok = await verifyPassword(password, row.password as string);
170
+ if (!ok) return null;
171
+ const user = toUser(row);
172
+ return { user, token: signToken(user) };
173
+ }
174
+
175
+ export async function seedAdmin(db: Database): Promise<void> {
176
+ const exists = await db.getOne("SELECT id FROM users WHERE role = ?", ["admin"]);
177
+ if (exists) return;
178
+ const email = appConfig("ADMIN_EMAIL") || "admin@nexa.dev";
179
+ const password = appConfig("ADMIN_PASSWORD") || "123456";
180
+ const hash = await hashPassword(password);
181
+ await db.insert("users", {
182
+ email,
183
+ password: hash,
184
+ role: "admin",
185
+ email_verified_at: new Date().toISOString(),
186
+ });
187
+ }
188
+
189
+ export function hasRole(user: AuthUser | null, role: string): boolean {
190
+ if (!user) return false;
191
+ if (user.role === "admin") return true;
192
+ return user.role === role;
193
+ }
194
+
195
+ /** Request password reset — emails a token (always returns ok to avoid email enumeration) */
196
+ export async function forgotPassword(email: string): Promise<{ sent: boolean }> {
197
+ if (!dbRef) throw new Error("Call start() first");
198
+ const row = await dbRef.getOne("SELECT * FROM users WHERE email = ?", [email]);
199
+ if (!row) return { sent: true };
200
+
201
+ const token = randomToken();
202
+ const expires = new Date(Date.now() + 60 * 60 * 1000).toISOString();
203
+ await dbRef.run(`DELETE FROM password_resets WHERE email = ?`, [email]);
204
+ await dbRef.insert("password_resets", { email, token, expires_at: expires });
205
+
206
+ const appUrl = appConfig("APP_URL") || "http://localhost:3333";
207
+ const link = `${appUrl}/reset-password?token=${token}&email=${encodeURIComponent(email)}`;
208
+ await mail(
209
+ email,
210
+ "Reset password",
211
+ `Reset your password:\n\n${link}\n\nToken: ${token}\nExpires in 1 hour.`
212
+ );
213
+ return { sent: true };
214
+ }
215
+
216
+ export async function resetPassword(
217
+ email: string,
218
+ token: string,
219
+ password: string
220
+ ): Promise<boolean> {
221
+ if (!dbRef) throw new Error("Call start() first");
222
+ const row = await dbRef.getOne(
223
+ `SELECT * FROM password_resets WHERE email = ? AND token = ?`,
224
+ [email, token]
225
+ );
226
+ if (!row) return false;
227
+ if (new Date(String(row.expires_at)).getTime() < Date.now()) {
228
+ await dbRef.run(`DELETE FROM password_resets WHERE email = ?`, [email]);
229
+ return false;
230
+ }
231
+
232
+ const hash = await hashPassword(password);
233
+ await dbRef.run(`UPDATE users SET password = ? WHERE email = ?`, [hash, email]);
234
+ await dbRef.run(`DELETE FROM password_resets WHERE email = ?`, [email]);
235
+ return true;
236
+ }
237
+
238
+ export async function sendVerificationEmail(user: AuthUser): Promise<void> {
239
+ if (!dbRef) return;
240
+ const token = randomToken();
241
+ const expires = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
242
+ await dbRef.run(`DELETE FROM password_resets WHERE email = ? AND token LIKE 'verify:%'`, [
243
+ user.email,
244
+ ]);
245
+ await dbRef.insert("password_resets", {
246
+ email: user.email,
247
+ token: `verify:${token}`,
248
+ expires_at: expires,
249
+ });
250
+
251
+ const appUrl = appConfig("APP_URL") || "http://localhost:3333";
252
+ const link = `${appUrl}/api/auth/verify-email?token=${token}&email=${encodeURIComponent(user.email)}`;
253
+ await mail(
254
+ user.email,
255
+ "Verify your email",
256
+ `Verify your email:\n\n${link}\n\nOr POST /api/auth/verify-email with { email, token }`
257
+ );
258
+ }
259
+
260
+ export async function verifyEmail(email: string, token: string): Promise<boolean> {
261
+ if (!dbRef) throw new Error("Call start() first");
262
+ const row = await dbRef.getOne(
263
+ `SELECT * FROM password_resets WHERE email = ? AND token = ?`,
264
+ [email, `verify:${token}`]
265
+ );
266
+ if (!row) return false;
267
+ if (new Date(String(row.expires_at)).getTime() < Date.now()) return false;
268
+
269
+ await dbRef.run(`UPDATE users SET email_verified_at = ? WHERE email = ?`, [
270
+ new Date().toISOString(),
271
+ email,
272
+ ]);
273
+ await dbRef.run(`DELETE FROM password_resets WHERE email = ? AND token = ?`, [
274
+ email,
275
+ `verify:${token}`,
276
+ ]);
277
+ return true;
278
+ }
279
+
280
+ export async function resendVerification(email: string): Promise<boolean> {
281
+ if (!dbRef) return false;
282
+ const row = await dbRef.getOne("SELECT * FROM users WHERE email = ?", [email]);
283
+ if (!row) return false;
284
+ if (row.email_verified_at) return true;
285
+ await sendVerificationEmail(toUser(row));
286
+ return true;
287
+ }
@@ -0,0 +1,17 @@
1
+ export {
2
+ setupAuth,
3
+ register,
4
+ login,
5
+ signToken,
6
+ verifyToken,
7
+ userFromRequest,
8
+ hasRole,
9
+ seedAdmin,
10
+ forgotPassword,
11
+ resetPassword,
12
+ sendVerificationEmail,
13
+ verifyEmail,
14
+ resendVerification,
15
+ type AuthUser,
16
+ type AuthConfig,
17
+ } from "./auth.js";