@aruvili/api 0.1.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/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +34 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +7 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +3 -0
- package/dist/context.js.map +1 -0
- package/dist/controllers/index.d.ts +39 -0
- package/dist/controllers/index.d.ts.map +1 -0
- package/dist/controllers/index.js +39 -0
- package/dist/controllers/index.js.map +1 -0
- package/dist/db.d.ts +6 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +74 -0
- package/dist/db.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +154 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/auth.d.ts +15 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +93 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/body-limit.d.ts +9 -0
- package/dist/middleware/body-limit.d.ts.map +1 -0
- package/dist/middleware/body-limit.js +15 -0
- package/dist/middleware/body-limit.js.map +1 -0
- package/dist/middleware/rate-limit.d.ts +6 -0
- package/dist/middleware/rate-limit.d.ts.map +1 -0
- package/dist/middleware/rate-limit.js +40 -0
- package/dist/middleware/rate-limit.js.map +1 -0
- package/dist/middleware/rbac.d.ts +10 -0
- package/dist/middleware/rbac.d.ts.map +1 -0
- package/dist/middleware/rbac.js +61 -0
- package/dist/middleware/rbac.js.map +1 -0
- package/dist/middleware/tenant.d.ts +3 -0
- package/dist/middleware/tenant.d.ts.map +1 -0
- package/dist/middleware/tenant.js +19 -0
- package/dist/middleware/tenant.js.map +1 -0
- package/dist/registry.d.ts +26 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +112 -0
- package/dist/registry.js.map +1 -0
- package/dist/routes/auth.d.ts +3 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +141 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/crud.d.ts +7 -0
- package/dist/routes/crud.d.ts.map +1 -0
- package/dist/routes/crud.js +845 -0
- package/dist/routes/crud.js.map +1 -0
- package/dist/routes/files.d.ts +7 -0
- package/dist/routes/files.d.ts.map +1 -0
- package/dist/routes/files.js +123 -0
- package/dist/routes/files.js.map +1 -0
- package/dist/routes/meta.d.ts +3 -0
- package/dist/routes/meta.d.ts.map +1 -0
- package/dist/routes/meta.js +352 -0
- package/dist/routes/meta.js.map +1 -0
- package/dist/scheduler.d.ts +33 -0
- package/dist/scheduler.d.ts.map +1 -0
- package/dist/scheduler.js +97 -0
- package/dist/scheduler.js.map +1 -0
- package/dist/utils/link-validator.d.ts +7 -0
- package/dist/utils/link-validator.d.ts.map +1 -0
- package/dist/utils/link-validator.js +33 -0
- package/dist/utils/link-validator.js.map +1 -0
- package/dist/utils/resolver.d.ts +5 -0
- package/dist/utils/resolver.d.ts.map +1 -0
- package/dist/utils/resolver.js +58 -0
- package/dist/utils/resolver.js.map +1 -0
- package/package.json +24 -0
- package/src/api.test.ts +362 -0
- package/src/config.d.ts +22 -0
- package/src/config.d.ts.map +1 -0
- package/src/config.js +34 -0
- package/src/config.js.map +1 -0
- package/src/config.ts +38 -0
- package/src/context.d.ts +7 -0
- package/src/context.d.ts.map +1 -0
- package/src/context.js +3 -0
- package/src/context.js.map +1 -0
- package/src/context.ts +8 -0
- package/src/controllers/index.d.ts +39 -0
- package/src/controllers/index.d.ts.map +1 -0
- package/src/controllers/index.js +39 -0
- package/src/controllers/index.js.map +1 -0
- package/src/controllers/index.ts +51 -0
- package/src/db.d.ts +6 -0
- package/src/db.d.ts.map +1 -0
- package/src/db.js +74 -0
- package/src/db.js.map +1 -0
- package/src/db.ts +73 -0
- package/src/index.ts +178 -0
- package/src/integration.test.ts +453 -0
- package/src/middleware/auth.d.ts +15 -0
- package/src/middleware/auth.d.ts.map +1 -0
- package/src/middleware/auth.js +93 -0
- package/src/middleware/auth.js.map +1 -0
- package/src/middleware/auth.ts +109 -0
- package/src/middleware/body-limit.d.ts +9 -0
- package/src/middleware/body-limit.d.ts.map +1 -0
- package/src/middleware/body-limit.js +15 -0
- package/src/middleware/body-limit.js.map +1 -0
- package/src/middleware/body-limit.ts +16 -0
- package/src/middleware/rate-limit.d.ts +6 -0
- package/src/middleware/rate-limit.d.ts.map +1 -0
- package/src/middleware/rate-limit.js +40 -0
- package/src/middleware/rate-limit.js.map +1 -0
- package/src/middleware/rate-limit.ts +47 -0
- package/src/middleware/rbac.d.ts +10 -0
- package/src/middleware/rbac.d.ts.map +1 -0
- package/src/middleware/rbac.js +61 -0
- package/src/middleware/rbac.js.map +1 -0
- package/src/middleware/rbac.ts +71 -0
- package/src/middleware/tenant.d.ts +3 -0
- package/src/middleware/tenant.d.ts.map +1 -0
- package/src/middleware/tenant.js +19 -0
- package/src/middleware/tenant.js.map +1 -0
- package/src/middleware/tenant.ts +24 -0
- package/src/registry.d.ts +26 -0
- package/src/registry.d.ts.map +1 -0
- package/src/registry.js +112 -0
- package/src/registry.js.map +1 -0
- package/src/registry.ts +123 -0
- package/src/routes/auth.d.ts +3 -0
- package/src/routes/auth.d.ts.map +1 -0
- package/src/routes/auth.js +141 -0
- package/src/routes/auth.js.map +1 -0
- package/src/routes/auth.ts +164 -0
- package/src/routes/crud.d.ts +7 -0
- package/src/routes/crud.d.ts.map +1 -0
- package/src/routes/crud.js +845 -0
- package/src/routes/crud.js.map +1 -0
- package/src/routes/crud.ts +1029 -0
- package/src/routes/files.d.ts +7 -0
- package/src/routes/files.d.ts.map +1 -0
- package/src/routes/files.js +123 -0
- package/src/routes/files.js.map +1 -0
- package/src/routes/files.ts +143 -0
- package/src/routes/meta.d.ts +3 -0
- package/src/routes/meta.d.ts.map +1 -0
- package/src/routes/meta.js +352 -0
- package/src/routes/meta.js.map +1 -0
- package/src/routes/meta.ts +448 -0
- package/src/scheduler.ts +118 -0
- package/src/utils/link-validator.d.ts +7 -0
- package/src/utils/link-validator.d.ts.map +1 -0
- package/src/utils/link-validator.js +33 -0
- package/src/utils/link-validator.js.map +1 -0
- package/src/utils/link-validator.ts +45 -0
- package/src/utils/resolver.d.ts +5 -0
- package/src/utils/resolver.d.ts.map +1 -0
- package/src/utils/resolver.js +58 -0
- package/src/utils/resolver.js.map +1 -0
- package/src/utils/resolver.ts +65 -0
- package/tsconfig.json +9 -0
package/src/registry.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { DocTypeDefinition } from '@aruvili/core';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { query } from './db.js';
|
|
4
|
+
|
|
5
|
+
class DocTypeRegistry {
|
|
6
|
+
private cache = new Map<string, DocTypeDefinition>();
|
|
7
|
+
private zodSchemas = new Map<string, z.ZodObject<any>>();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Clears in-memory metadata caches.
|
|
11
|
+
*/
|
|
12
|
+
public invalidate(doctypeName: string) {
|
|
13
|
+
this.cache.delete(doctypeName);
|
|
14
|
+
this.zodSchemas.delete(doctypeName);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Retrieves a DocTypeDefinition schema structure.
|
|
19
|
+
* Loads dynamically from database into cache if missing.
|
|
20
|
+
*/
|
|
21
|
+
public async get(doctypeName: string): Promise<DocTypeDefinition | null> {
|
|
22
|
+
if (this.cache.has(doctypeName)) {
|
|
23
|
+
return this.cache.get(doctypeName)!;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Attempt to load from PostgreSQL meta configuration table
|
|
27
|
+
try {
|
|
28
|
+
const res = await query('SELECT definition FROM _doctype_meta WHERE name = $1', [doctypeName]);
|
|
29
|
+
if (res.rows.length === 0) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const definition = res.rows[0].definition as DocTypeDefinition;
|
|
33
|
+
this.cache.set(doctypeName, definition);
|
|
34
|
+
return definition;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error(`Failed to load DocType ${doctypeName} from database:`, err);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Sets definition details to registry and clear caches.
|
|
43
|
+
*/
|
|
44
|
+
public set(doctypeName: string, definition: DocTypeDefinition) {
|
|
45
|
+
this.cache.set(doctypeName, definition);
|
|
46
|
+
this.zodSchemas.delete(doctypeName);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compiles and returns a runtime Zod validator matching the fields configuration.
|
|
51
|
+
*/
|
|
52
|
+
public async getValidator(doctypeName: string): Promise<z.ZodObject<any> | null> {
|
|
53
|
+
if (this.zodSchemas.has(doctypeName)) {
|
|
54
|
+
return this.zodSchemas.get(doctypeName)!;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const definition = await this.get(doctypeName);
|
|
58
|
+
if (!definition) return null;
|
|
59
|
+
|
|
60
|
+
const shape: Record<string, z.ZodTypeAny> = {
|
|
61
|
+
name: z.string().max(255).optional(),
|
|
62
|
+
docstatus: z.number().int().min(0).max(2).optional()
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
for (const field of definition.fields) {
|
|
66
|
+
let validator: z.ZodTypeAny;
|
|
67
|
+
|
|
68
|
+
switch (field.fieldtype) {
|
|
69
|
+
case 'Select':
|
|
70
|
+
if (field.options) {
|
|
71
|
+
const list = field.options.split(',').map(s => s.trim());
|
|
72
|
+
validator = z.enum(list as [string, ...string[]]);
|
|
73
|
+
} else {
|
|
74
|
+
validator = z.string().max(255);
|
|
75
|
+
}
|
|
76
|
+
break;
|
|
77
|
+
case 'Text':
|
|
78
|
+
case 'Link':
|
|
79
|
+
validator = z.string().max(255);
|
|
80
|
+
break;
|
|
81
|
+
case 'Small Text':
|
|
82
|
+
case 'Long Text':
|
|
83
|
+
validator = z.string();
|
|
84
|
+
break;
|
|
85
|
+
case 'Int':
|
|
86
|
+
validator = z.number().int();
|
|
87
|
+
break;
|
|
88
|
+
case 'Float':
|
|
89
|
+
case 'Currency':
|
|
90
|
+
validator = z.number();
|
|
91
|
+
break;
|
|
92
|
+
case 'Check':
|
|
93
|
+
validator = z.boolean();
|
|
94
|
+
break;
|
|
95
|
+
case 'Date':
|
|
96
|
+
validator = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Must be in YYYY-MM-DD format');
|
|
97
|
+
break;
|
|
98
|
+
case 'Datetime':
|
|
99
|
+
validator = z.string().datetime();
|
|
100
|
+
break;
|
|
101
|
+
case 'Table':
|
|
102
|
+
// Child table fields are arrays of validations compiled from the child schema.
|
|
103
|
+
// Note: to prevent recursion, retrieve the child validator dynamically.
|
|
104
|
+
validator = z.array(z.any()); // Validated down the pipeline inside CRUD controller transaction loop
|
|
105
|
+
break;
|
|
106
|
+
default:
|
|
107
|
+
validator = z.any();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!field.required) {
|
|
111
|
+
validator = validator.optional().nullable();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
shape[field.fieldname] = validator;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const compiled = z.object(shape);
|
|
118
|
+
this.zodSchemas.set(doctypeName, compiled);
|
|
119
|
+
return compiled;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export const registry = new DocTypeRegistry();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAK5B,eAAO,MAAM,UAAU,4EAAa,CAAC"}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { config } from '../config.js';
|
|
3
|
+
import { query, withTransaction } from '../db.js';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
export const authRouter = new Hono();
|
|
6
|
+
/**
|
|
7
|
+
* Hash password using native crypto (no bcrypt dependency needed in Bun).
|
|
8
|
+
*/
|
|
9
|
+
async function hashPassword(password) {
|
|
10
|
+
const salt = crypto.randomBytes(32).toString('hex');
|
|
11
|
+
return new Promise((resolve, reject) => {
|
|
12
|
+
crypto.scrypt(password, salt, 64, (err, derived) => {
|
|
13
|
+
if (err)
|
|
14
|
+
reject(err);
|
|
15
|
+
resolve(`${salt}:${derived.toString('hex')}`);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
async function verifyPassword(password, hash) {
|
|
20
|
+
const [salt, key] = hash.split(':');
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
crypto.scrypt(password, salt, 64, (err, derived) => {
|
|
23
|
+
if (err)
|
|
24
|
+
reject(err);
|
|
25
|
+
resolve(derived.toString('hex') === key);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Generate a signed JWT token.
|
|
31
|
+
*/
|
|
32
|
+
function signJWT(payload) {
|
|
33
|
+
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
|
34
|
+
const now = Math.floor(Date.now() / 1000);
|
|
35
|
+
const body = Buffer.from(JSON.stringify({
|
|
36
|
+
...payload,
|
|
37
|
+
iat: now,
|
|
38
|
+
exp: now + config.jwtExpiresIn
|
|
39
|
+
})).toString('base64url');
|
|
40
|
+
const signature = crypto
|
|
41
|
+
.createHmac('sha256', config.jwtSecret)
|
|
42
|
+
.update(`${header}.${body}`)
|
|
43
|
+
.digest('base64url');
|
|
44
|
+
return `${header}.${body}.${signature}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* POST /api/auth/signup
|
|
48
|
+
*/
|
|
49
|
+
authRouter.post('/signup', async (c) => {
|
|
50
|
+
const { email, password, full_name } = await c.req.json();
|
|
51
|
+
if (!email || !password) {
|
|
52
|
+
return c.json({ error: 'Email and password are required' }, 400);
|
|
53
|
+
}
|
|
54
|
+
if (typeof password !== 'string' || password.length < 8) {
|
|
55
|
+
return c.json({ error: 'Password must be at least 8 characters' }, 400);
|
|
56
|
+
}
|
|
57
|
+
if (typeof email !== 'string' || !email.includes('@')) {
|
|
58
|
+
return c.json({ error: 'Invalid email address' }, 400);
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const existing = await query('SELECT name FROM _users WHERE email = $1', [email.toLowerCase()]);
|
|
62
|
+
if (existing.rows.length > 0) {
|
|
63
|
+
return c.json({ error: 'An account with this email already exists' }, 409);
|
|
64
|
+
}
|
|
65
|
+
const hashed = await hashPassword(password);
|
|
66
|
+
const userId = crypto.randomUUID();
|
|
67
|
+
await withTransaction(async (client) => {
|
|
68
|
+
await client.query(`INSERT INTO _users (name, uuid, email, full_name, hashed_password, roles, enabled)
|
|
69
|
+
VALUES ($1, gen_random_uuid(), $2, $3, $4, $5, true)`, [userId, email.toLowerCase(), full_name || '', hashed, JSON.stringify(['Guest'])]);
|
|
70
|
+
});
|
|
71
|
+
const token = signJWT({ email: email.toLowerCase(), roles: ['Guest'], user_id: userId });
|
|
72
|
+
return c.json({ token, user: { name: userId, email: email.toLowerCase(), roles: ['Guest'] } }, 201);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
console.error('[AUTH] Signup failed:', err.message);
|
|
76
|
+
return c.json({ error: 'Registration failed' }, 500);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
/**
|
|
80
|
+
* POST /api/auth/login
|
|
81
|
+
*/
|
|
82
|
+
authRouter.post('/login', async (c) => {
|
|
83
|
+
const { email, password } = await c.req.json();
|
|
84
|
+
if (!email || !password) {
|
|
85
|
+
return c.json({ error: 'Email and password are required' }, 400);
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const res = await query('SELECT name, email, hashed_password, roles, enabled FROM _users WHERE email = $1', [email.toLowerCase()]);
|
|
89
|
+
if (res.rows.length === 0) {
|
|
90
|
+
return c.json({ error: 'Invalid email or password' }, 401);
|
|
91
|
+
}
|
|
92
|
+
const user = res.rows[0];
|
|
93
|
+
if (!user.enabled) {
|
|
94
|
+
return c.json({ error: 'Account is disabled' }, 403);
|
|
95
|
+
}
|
|
96
|
+
const valid = await verifyPassword(password, user.hashed_password);
|
|
97
|
+
if (!valid) {
|
|
98
|
+
return c.json({ error: 'Invalid email or password' }, 401);
|
|
99
|
+
}
|
|
100
|
+
const roles = Array.isArray(user.roles) ? user.roles : JSON.parse(user.roles || '["Guest"]');
|
|
101
|
+
const token = signJWT({ email: user.email, roles, user_id: user.name });
|
|
102
|
+
// Update last login
|
|
103
|
+
await query('UPDATE _users SET last_login = NOW() WHERE name = $1', [user.name]);
|
|
104
|
+
return c.json({ token, user: { name: user.name, email: user.email, roles } });
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
console.error('[AUTH] Login failed:', err.message);
|
|
108
|
+
return c.json({ error: 'Authentication failed' }, 500);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
/**
|
|
112
|
+
* GET /api/auth/me — returns current session user info
|
|
113
|
+
*/
|
|
114
|
+
authRouter.get('/me', async (c) => {
|
|
115
|
+
const authHeader = c.req.header('Authorization');
|
|
116
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
117
|
+
return c.json({ error: 'Not authenticated' }, 401);
|
|
118
|
+
}
|
|
119
|
+
const token = authHeader.split(' ')[1];
|
|
120
|
+
try {
|
|
121
|
+
const parts = token.split('.');
|
|
122
|
+
if (parts.length !== 3)
|
|
123
|
+
return c.json({ error: 'Invalid token' }, 401);
|
|
124
|
+
const sig = crypto.createHmac('sha256', config.jwtSecret).update(`${parts[0]}.${parts[1]}`).digest('base64url');
|
|
125
|
+
if (sig !== parts[2])
|
|
126
|
+
return c.json({ error: 'Invalid token' }, 401);
|
|
127
|
+
const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString());
|
|
128
|
+
if (payload.exp && Date.now() / 1000 > payload.exp)
|
|
129
|
+
return c.json({ error: 'Token expired' }, 401);
|
|
130
|
+
const res = await query('SELECT name, email, full_name, roles, enabled, last_login FROM _users WHERE email = $1', [payload.email]);
|
|
131
|
+
if (res.rows.length === 0)
|
|
132
|
+
return c.json({ error: 'User not found' }, 404);
|
|
133
|
+
const user = res.rows[0];
|
|
134
|
+
user.roles = Array.isArray(user.roles) ? user.roles : JSON.parse(user.roles || '[]');
|
|
135
|
+
return c.json(user);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return c.json({ error: 'Invalid token' }, 401);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
//# sourceMappingURL=auth.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { config } from '../config.js';
|
|
3
|
+
import { query, withTransaction } from '../db.js';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
|
|
6
|
+
export const authRouter = new Hono();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Hash password using native crypto (no bcrypt dependency needed in Bun).
|
|
10
|
+
*/
|
|
11
|
+
async function hashPassword(password: string): Promise<string> {
|
|
12
|
+
const salt = crypto.randomBytes(32).toString('hex');
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
crypto.scrypt(password, salt, 64, (err, derived) => {
|
|
15
|
+
if (err) reject(err);
|
|
16
|
+
resolve(`${salt}:${derived.toString('hex')}`);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
|
22
|
+
const [salt, key] = hash.split(':');
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
crypto.scrypt(password, salt, 64, (err, derived) => {
|
|
25
|
+
if (err) reject(err);
|
|
26
|
+
resolve(derived.toString('hex') === key);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Generate a signed JWT token.
|
|
33
|
+
*/
|
|
34
|
+
function signJWT(payload: Record<string, any>): string {
|
|
35
|
+
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
|
36
|
+
const now = Math.floor(Date.now() / 1000);
|
|
37
|
+
const body = Buffer.from(JSON.stringify({
|
|
38
|
+
...payload,
|
|
39
|
+
iat: now,
|
|
40
|
+
exp: now + config.jwtExpiresIn
|
|
41
|
+
})).toString('base64url');
|
|
42
|
+
const signature = crypto
|
|
43
|
+
.createHmac('sha256', config.jwtSecret)
|
|
44
|
+
.update(`${header}.${body}`)
|
|
45
|
+
.digest('base64url');
|
|
46
|
+
return `${header}.${body}.${signature}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* POST /api/auth/signup
|
|
51
|
+
*/
|
|
52
|
+
authRouter.post('/signup', async (c) => {
|
|
53
|
+
const { email, password, full_name } = await c.req.json();
|
|
54
|
+
|
|
55
|
+
if (!email || !password) {
|
|
56
|
+
return c.json({ error: 'Email and password are required' }, 400);
|
|
57
|
+
}
|
|
58
|
+
if (typeof password !== 'string' || password.length < 8) {
|
|
59
|
+
return c.json({ error: 'Password must be at least 8 characters' }, 400);
|
|
60
|
+
}
|
|
61
|
+
if (typeof email !== 'string' || !email.includes('@')) {
|
|
62
|
+
return c.json({ error: 'Invalid email address' }, 400);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const existing = await query('SELECT name FROM _users WHERE email = $1', [email.toLowerCase()]);
|
|
67
|
+
if (existing.rows.length > 0) {
|
|
68
|
+
return c.json({ error: 'An account with this email already exists' }, 409);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const hashed = await hashPassword(password);
|
|
72
|
+
const userId = crypto.randomUUID();
|
|
73
|
+
|
|
74
|
+
await withTransaction(async (client) => {
|
|
75
|
+
await client.query(
|
|
76
|
+
`INSERT INTO _users (name, uuid, email, full_name, hashed_password, roles, enabled)
|
|
77
|
+
VALUES ($1, gen_random_uuid(), $2, $3, $4, $5, true)`,
|
|
78
|
+
[userId, email.toLowerCase(), full_name || '', hashed, JSON.stringify(['Guest'])]
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const token = signJWT({ email: email.toLowerCase(), roles: ['Guest'], user_id: userId });
|
|
83
|
+
|
|
84
|
+
return c.json({ token, user: { name: userId, email: email.toLowerCase(), roles: ['Guest'] } }, 201);
|
|
85
|
+
} catch (err: any) {
|
|
86
|
+
console.error('[AUTH] Signup failed:', err.message);
|
|
87
|
+
return c.json({ error: 'Registration failed' }, 500);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* POST /api/auth/login
|
|
93
|
+
*/
|
|
94
|
+
authRouter.post('/login', async (c) => {
|
|
95
|
+
const { email, password } = await c.req.json();
|
|
96
|
+
|
|
97
|
+
if (!email || !password) {
|
|
98
|
+
return c.json({ error: 'Email and password are required' }, 400);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const res = await query(
|
|
103
|
+
'SELECT name, email, hashed_password, roles, enabled FROM _users WHERE email = $1',
|
|
104
|
+
[email.toLowerCase()]
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (res.rows.length === 0) {
|
|
108
|
+
return c.json({ error: 'Invalid email or password' }, 401);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const user = res.rows[0];
|
|
112
|
+
|
|
113
|
+
if (!user.enabled) {
|
|
114
|
+
return c.json({ error: 'Account is disabled' }, 403);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const valid = await verifyPassword(password, user.hashed_password);
|
|
118
|
+
if (!valid) {
|
|
119
|
+
return c.json({ error: 'Invalid email or password' }, 401);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const roles = Array.isArray(user.roles) ? user.roles : JSON.parse(user.roles || '["Guest"]');
|
|
123
|
+
const token = signJWT({ email: user.email, roles, user_id: user.name });
|
|
124
|
+
|
|
125
|
+
// Update last login
|
|
126
|
+
await query('UPDATE _users SET last_login = NOW() WHERE name = $1', [user.name]);
|
|
127
|
+
|
|
128
|
+
return c.json({ token, user: { name: user.name, email: user.email, roles } });
|
|
129
|
+
} catch (err: any) {
|
|
130
|
+
console.error('[AUTH] Login failed:', err.message);
|
|
131
|
+
return c.json({ error: 'Authentication failed' }, 500);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* GET /api/auth/me — returns current session user info
|
|
137
|
+
*/
|
|
138
|
+
authRouter.get('/me', async (c) => {
|
|
139
|
+
const authHeader = c.req.header('Authorization');
|
|
140
|
+
if (!authHeader?.startsWith('Bearer ')) {
|
|
141
|
+
return c.json({ error: 'Not authenticated' }, 401);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const token = authHeader.split(' ')[1];
|
|
145
|
+
try {
|
|
146
|
+
const parts = token.split('.');
|
|
147
|
+
if (parts.length !== 3) return c.json({ error: 'Invalid token' }, 401);
|
|
148
|
+
|
|
149
|
+
const sig = crypto.createHmac('sha256', config.jwtSecret).update(`${parts[0]}.${parts[1]}`).digest('base64url');
|
|
150
|
+
if (sig !== parts[2]) return c.json({ error: 'Invalid token' }, 401);
|
|
151
|
+
|
|
152
|
+
const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString());
|
|
153
|
+
if (payload.exp && Date.now() / 1000 > payload.exp) return c.json({ error: 'Token expired' }, 401);
|
|
154
|
+
|
|
155
|
+
const res = await query('SELECT name, email, full_name, roles, enabled, last_login FROM _users WHERE email = $1', [payload.email]);
|
|
156
|
+
if (res.rows.length === 0) return c.json({ error: 'User not found' }, 404);
|
|
157
|
+
|
|
158
|
+
const user = res.rows[0];
|
|
159
|
+
user.roles = Array.isArray(user.roles) ? user.roles : JSON.parse(user.roles || '[]');
|
|
160
|
+
return c.json(user);
|
|
161
|
+
} catch {
|
|
162
|
+
return c.json({ error: 'Invalid token' }, 401);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crud.d.ts","sourceRoot":"","sources":["crud.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAS5B,eAAO,MAAM,UAAU;eAAyB;QAAE,IAAI,EAAE,GAAG,CAAA;KAAE;yCAAK,CAAC"}
|