@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.
- package/.env.example +46 -0
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/bin/nexa.mjs +41 -0
- package/bin/nexa.ts +334 -0
- package/docs/AI.md +69 -0
- package/docs/ARCHITECTURE.md +74 -0
- package/docs/EXAMPLES.md +114 -0
- package/docs/FRAMEWORK.md +226 -0
- package/docs/LANGUAGE.md +39 -0
- package/docs/README.md +7 -0
- package/docs/READY.md +51 -0
- package/docs/REFERENCE.md +255 -0
- package/docs/START.md +98 -0
- package/docs/advanced.md +97 -0
- package/docs/authentication.md +54 -0
- package/docs/cli.md +15 -0
- package/docs/compare.md +51 -0
- package/docs/configuration.md +65 -0
- package/docs/database.md +396 -0
- package/docs/installation.md +57 -0
- package/docs/localization.md +47 -0
- package/docs/resources.md +75 -0
- package/docs/routing.md +59 -0
- package/docs/seeding.md +33 -0
- package/docs/services.md +146 -0
- package/package.json +77 -0
- package/packages/auth/src/auth.test.ts +23 -0
- package/packages/auth/src/auth.ts +287 -0
- package/packages/auth/src/index.ts +17 -0
- package/packages/cache/src/index.ts +203 -0
- package/packages/client/src/index.ts +49 -0
- package/packages/core/src/app.ts +97 -0
- package/packages/core/src/config.ts +55 -0
- package/packages/core/src/dev.ts +104 -0
- package/packages/core/src/fields.test.ts +81 -0
- package/packages/core/src/fields.ts +309 -0
- package/packages/core/src/index.ts +60 -0
- package/packages/core/src/lang.ts +79 -0
- package/packages/core/src/loader.ts +42 -0
- package/packages/core/src/migrate.ts +91 -0
- package/packages/core/src/policy.ts +36 -0
- package/packages/core/src/registry.ts +17 -0
- package/packages/core/src/reload.ts +92 -0
- package/packages/core/src/resource.test.ts +32 -0
- package/packages/core/src/resource.ts +87 -0
- package/packages/core/src/routes.ts +22 -0
- package/packages/core/src/runtime.ts +11 -0
- package/packages/database/src/builder.ts +266 -0
- package/packages/database/src/database.ts +252 -0
- package/packages/database/src/dialect.ts +186 -0
- package/packages/database/src/index.ts +6 -0
- package/packages/database/src/mysql.ts +114 -0
- package/packages/database/src/postgres.ts +117 -0
- package/packages/database/src/query.ts +115 -0
- package/packages/database/src/sqlite.ts +216 -0
- package/packages/database/src/types.ts +104 -0
- package/packages/events/src/index.ts +17 -0
- package/packages/export/src/index.ts +36 -0
- package/packages/log/src/index.ts +38 -0
- package/packages/mail/src/index.ts +130 -0
- package/packages/notifications/src/index.ts +84 -0
- package/packages/plugins/src/index.ts +39 -0
- package/packages/queue/src/index.ts +185 -0
- package/packages/queue/src/jobs.ts +9 -0
- package/packages/schedule/src/index.ts +64 -0
- package/packages/server/src/index.ts +1 -0
- package/packages/server/src/middleware.ts +143 -0
- package/packages/server/src/query.ts +40 -0
- package/packages/server/src/router.ts +813 -0
- package/packages/sms/src/index.ts +33 -0
- package/packages/storage/src/upload.ts +36 -0
- package/packages/testing/src/index.ts +67 -0
- package/packages/validation/src/index.ts +1 -0
- package/packages/validation/src/validate.test.ts +35 -0
- package/packages/validation/src/validate.ts +112 -0
- package/public/admin.html +369 -0
- package/public/compare.html +66 -0
- package/public/dev-bar.js +213 -0
- package/public/docs.html +315 -0
- package/public/index.html +66 -0
package/docs/services.md
ADDED
|
@@ -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";
|