rotifex 0.1.6 → 0.1.7
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/README.md +138 -59
- package/config.default.json +1 -1
- package/package.json +23 -4
- package/src/auth/auth.controller.js +50 -1
- package/src/auth/auth.routes.js +6 -4
- package/src/auth/auth.service.js +112 -4
- package/src/commands/start.js +40 -0
- package/src/engine/tableSync.js +34 -1
- package/src/lib/config.js +6 -4
- package/src/server/routes/admin.js +50 -0
- package/src/server/server.js +3 -1
package/README.md
CHANGED
|
@@ -30,6 +30,16 @@ npx rotifex start
|
|
|
30
30
|
| ------- | ------------------------------------ |
|
|
31
31
|
| `start` | Start the Rotifex development server |
|
|
32
32
|
|
|
33
|
+
### `start` flags
|
|
34
|
+
|
|
35
|
+
| Flag | Description |
|
|
36
|
+
| ----------------- | -------------------------------------------------------------------- |
|
|
37
|
+
| `-p, --port <n>` | TCP port to listen on (overrides `ROTIFEX_PORT` and auto-fallback) |
|
|
38
|
+
| `--host <host>` | Bind address (overrides `ROTIFEX_HOST`) |
|
|
39
|
+
| `--verbose` | Enable debug-level logging |
|
|
40
|
+
|
|
41
|
+
**Port auto-fallback:** If no port is explicitly set, Rotifex tries port `4994`, then `4995`, then `4996`. If all three are occupied it exits with a clear error. Pass `--port` or set `ROTIFEX_PORT` to pin a specific port and skip the fallback.
|
|
42
|
+
|
|
33
43
|
## Table of Contents
|
|
34
44
|
|
|
35
45
|
1. [Application Overview](#1-application-overview)
|
|
@@ -116,7 +126,7 @@ Rotifex eliminates boilerplate for backend development. Instead of writing CRUD
|
|
|
116
126
|
**How it works:**
|
|
117
127
|
|
|
118
128
|
1. `schema.json` is parsed by `schemaLoader.js` into normalized model definitions.
|
|
119
|
-
2. `tableSync.js` runs `CREATE TABLE IF NOT EXISTS` for each model.
|
|
129
|
+
2. `tableSync.js` runs `CREATE TABLE IF NOT EXISTS` for each model. On subsequent startups it **automatically adds missing columns** via `ALTER TABLE ADD COLUMN` — no manual migrations needed when fields are added to an existing model.
|
|
120
130
|
3. `routeFactory.js` registers generic parametric routes (`/api/:table`) that resolve the model from an in-memory store at request time.
|
|
121
131
|
4. When a model is added/removed via the admin API, the in-memory store is updated and routes resolve immediately.
|
|
122
132
|
|
|
@@ -154,16 +164,20 @@ Rotifex eliminates boilerplate for backend development. Instead of writing CRUD
|
|
|
154
164
|
|
|
155
165
|
### 2.2 JWT Authentication
|
|
156
166
|
|
|
157
|
-
**Description:** Full authentication system with access tokens, refresh tokens, password hashing, and role-based access control.
|
|
167
|
+
**Description:** Full authentication system with access tokens, refresh tokens, token rotation, logout/revocation, password hashing, and role-based access control.
|
|
158
168
|
|
|
159
169
|
**Use case:** Secure user registration and login for a Rotifex-backed application. Protect admin routes from regular users.
|
|
160
170
|
|
|
161
171
|
**How it works:**
|
|
162
172
|
|
|
163
|
-
1. `POST /auth/register` hashes the password with bcrypt and inserts a user row.
|
|
164
|
-
2. `POST /auth/login` verifies credentials and issues a short-lived access token
|
|
173
|
+
1. `POST /auth/register` hashes the password with bcrypt (12 rounds) and inserts a user row.
|
|
174
|
+
2. `POST /auth/login` verifies credentials and issues a short-lived access token and long-lived refresh token.
|
|
165
175
|
3. The JWT middleware runs on every request, verifies the `Authorization: Bearer` header, and injects `x-user-id` / `x-user-role` headers that downstream routes use for authorization.
|
|
166
|
-
4. `POST /auth/refresh` issues a new token pair
|
|
176
|
+
4. `POST /auth/refresh` issues a new token pair and **revokes the consumed refresh token** (single-use rotation). Each refresh token embeds a unique `jti` (JWT ID) used for targeted revocation.
|
|
177
|
+
5. `POST /auth/logout` invalidates the provided refresh token immediately.
|
|
178
|
+
6. `POST /auth/change-password` lets an authenticated user change their own password.
|
|
179
|
+
|
|
180
|
+
**Token TTLs are configurable** via env vars (see Section 10). Defaults: access token 60 minutes, refresh token 30 days. The refresh TTL must be at least 2× the access TTL and no shorter than 2 hours.
|
|
167
181
|
|
|
168
182
|
**Roles:** `user` (default) and `admin`. Admin access is required for all `/admin/api/*` endpoints.
|
|
169
183
|
|
|
@@ -252,7 +266,7 @@ Rotifex eliminates boilerplate for backend development. Instead of writing CRUD
|
|
|
252
266
|
### Base URL
|
|
253
267
|
|
|
254
268
|
```
|
|
255
|
-
http://localhost:
|
|
269
|
+
http://localhost:4994
|
|
256
270
|
```
|
|
257
271
|
|
|
258
272
|
All API responses follow the envelope format:
|
|
@@ -375,7 +389,7 @@ Authenticate and receive tokens.
|
|
|
375
389
|
|
|
376
390
|
#### `POST /auth/refresh`
|
|
377
391
|
|
|
378
|
-
Exchange a refresh token for a new token pair.
|
|
392
|
+
Exchange a refresh token for a new token pair. The consumed refresh token is immediately revoked (single-use rotation).
|
|
379
393
|
|
|
380
394
|
**Auth:** None
|
|
381
395
|
|
|
@@ -400,10 +414,61 @@ Exchange a refresh token for a new token pair.
|
|
|
400
414
|
|
|
401
415
|
**Errors:**
|
|
402
416
|
|
|
417
|
+
| Code | Reason |
|
|
418
|
+
| ----- | -------------------------------------------- |
|
|
419
|
+
| `400` | Missing refreshToken |
|
|
420
|
+
| `401` | Invalid, expired, or already-revoked token |
|
|
421
|
+
|
|
422
|
+
---
|
|
423
|
+
|
|
424
|
+
#### `POST /auth/logout`
|
|
425
|
+
|
|
426
|
+
Revoke a refresh token. After this call the token cannot be used to issue new pairs.
|
|
427
|
+
|
|
428
|
+
**Auth:** None
|
|
429
|
+
|
|
430
|
+
**Request Body:**
|
|
431
|
+
|
|
432
|
+
```json
|
|
433
|
+
{
|
|
434
|
+
"refreshToken": "<jwt>"
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
**Response `204`:** No content.
|
|
439
|
+
|
|
440
|
+
**Errors:**
|
|
441
|
+
|
|
403
442
|
| Code | Reason |
|
|
404
443
|
| ----- | -------------------------------- |
|
|
405
|
-
| `400` | Missing refreshToken
|
|
406
|
-
|
|
444
|
+
| `400` | Missing or invalid refreshToken |
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
#### `POST /auth/change-password`
|
|
449
|
+
|
|
450
|
+
Change the authenticated user's own password.
|
|
451
|
+
|
|
452
|
+
**Auth:** `Authorization: Bearer <accessToken>`
|
|
453
|
+
|
|
454
|
+
**Request Body:**
|
|
455
|
+
|
|
456
|
+
```json
|
|
457
|
+
{
|
|
458
|
+
"currentPassword": "old-password",
|
|
459
|
+
"newPassword": "new-password123"
|
|
460
|
+
}
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
**Response `204`:** No content.
|
|
464
|
+
|
|
465
|
+
**Errors:**
|
|
466
|
+
|
|
467
|
+
| Code | Reason |
|
|
468
|
+
| ----- | ----------------------------------- |
|
|
469
|
+
| `400` | Missing fields or weak new password |
|
|
470
|
+
| `401` | Wrong current password / bad token |
|
|
471
|
+
| `404` | User not found |
|
|
407
472
|
|
|
408
473
|
---
|
|
409
474
|
|
|
@@ -698,7 +763,7 @@ Generate a time-limited signed URL for a private file.
|
|
|
698
763
|
```json
|
|
699
764
|
{
|
|
700
765
|
"data": {
|
|
701
|
-
"url": "http://localhost:
|
|
766
|
+
"url": "http://localhost:4994/files/uuid/download?token=abc123&expires=1741300000",
|
|
702
767
|
"expires": 1741300000
|
|
703
768
|
}
|
|
704
769
|
}
|
|
@@ -1122,7 +1187,7 @@ Read current environment/config values.
|
|
|
1122
1187
|
{
|
|
1123
1188
|
"data": {
|
|
1124
1189
|
"JWT_SECRET": "***",
|
|
1125
|
-
"ROTIFEX_PORT": "
|
|
1190
|
+
"ROTIFEX_PORT": "4994",
|
|
1126
1191
|
"ROTIFEX_CORS_ORIGIN": "*"
|
|
1127
1192
|
}
|
|
1128
1193
|
}
|
|
@@ -1290,10 +1355,14 @@ Client Rotifex Server
|
|
|
1290
1355
|
|
|
1291
1356
|
### Token Details
|
|
1292
1357
|
|
|
1293
|
-
| Token | Algorithm | TTL
|
|
1294
|
-
| ------------- | --------- |
|
|
1295
|
-
| Access Token | HS256 |
|
|
1296
|
-
| Refresh Token | HS256 | 30 days | `JWT_REFRESH_SECRET` |
|
|
1358
|
+
| Token | Algorithm | Default TTL | TTL Env Var | Secret Env Var |
|
|
1359
|
+
| ------------- | --------- | ----------- | ----------------------------- | -------------------- |
|
|
1360
|
+
| Access Token | HS256 | 60 min | `ROTIFEX_ACCESS_TOKEN_TTL` | `JWT_SECRET` |
|
|
1361
|
+
| Refresh Token | HS256 | 30 days | `ROTIFEX_REFRESH_TOKEN_TTL` | `JWT_REFRESH_SECRET` |
|
|
1362
|
+
|
|
1363
|
+
TTLs are in **minutes**. The refresh TTL must be ≥ 2× the access TTL and ≥ 120 minutes. Both can be tuned in the admin **Settings** page or via `.env`.
|
|
1364
|
+
|
|
1365
|
+
Refresh tokens embed a unique `jti` (JWT ID) enabling individual revocation. Token rotation is enforced — each refresh token is single-use.
|
|
1297
1366
|
|
|
1298
1367
|
Secrets are auto-generated and saved to `.env` on first startup if not explicitly set.
|
|
1299
1368
|
|
|
@@ -1569,7 +1638,9 @@ Signed URLs are generated via `GET /files/:id/signed-url`. The default TTL is 1
|
|
|
1569
1638
|
### User Management
|
|
1570
1639
|
|
|
1571
1640
|
- List all registered users: email, display name, role, creation date
|
|
1572
|
-
-
|
|
1641
|
+
- **Create user** — "New User" modal with email, password, display name, and role
|
|
1642
|
+
- **Reset password** — inline "Reset Password" section inside the edit modal (admin force-sets any user's password)
|
|
1643
|
+
- Password validation enforced: minimum 8 characters, at least one letter and one number
|
|
1573
1644
|
|
|
1574
1645
|
### File Browser
|
|
1575
1646
|
|
|
@@ -1607,17 +1678,21 @@ Signed URLs are generated via `GET /files/:id/signed-url`. The default TTL is 1
|
|
|
1607
1678
|
|
|
1608
1679
|
Editable via admin panel — writes to `.env`:
|
|
1609
1680
|
|
|
1610
|
-
| Variable | Description
|
|
1611
|
-
| ----------------------------------- |
|
|
1612
|
-
| `
|
|
1613
|
-
| `
|
|
1614
|
-
| `
|
|
1615
|
-
| `
|
|
1616
|
-
| `
|
|
1617
|
-
| `
|
|
1618
|
-
| `
|
|
1619
|
-
| `
|
|
1620
|
-
| `
|
|
1681
|
+
| Variable | Description |
|
|
1682
|
+
| ----------------------------------- | --------------------------------------------------------------- |
|
|
1683
|
+
| `ROTIFEX_ACCESS_TOKEN_TTL` | Access token lifetime in minutes (min 5, default 60) |
|
|
1684
|
+
| `ROTIFEX_REFRESH_TOKEN_TTL` | Refresh token lifetime in minutes (min 120 and ≥ 2×access, default 43200) |
|
|
1685
|
+
| `JWT_SECRET` | Access token signing secret |
|
|
1686
|
+
| `JWT_REFRESH_SECRET` | Refresh token signing secret |
|
|
1687
|
+
| `ROTIFEX_PORT` | Server port |
|
|
1688
|
+
| `ROTIFEX_HOST` | Server bind host |
|
|
1689
|
+
| `ROTIFEX_CORS_ORIGIN` | Allowed CORS origin(s) |
|
|
1690
|
+
| `ROTIFEX_RATE_LIMIT_MAX` | Max requests per time window |
|
|
1691
|
+
| `ROTIFEX_LOG_LEVEL` | Log verbosity |
|
|
1692
|
+
| `ROTIFEX_STORAGE_MAX_FILE_SIZE_MB` | Max upload size in MB |
|
|
1693
|
+
| `ROTIFEX_STORAGE_SIGNED_URL_SECRET` | HMAC secret for signed URLs |
|
|
1694
|
+
|
|
1695
|
+
The **Token Timing** card validates constraints live: refresh TTL must be ≥ 2× access TTL and ≥ 120 minutes. The Save button is disabled until all errors are resolved.
|
|
1621
1696
|
|
|
1622
1697
|
---
|
|
1623
1698
|
|
|
@@ -1669,29 +1744,33 @@ Validation errors may return an array for `message`:
|
|
|
1669
1744
|
|
|
1670
1745
|
### Environment Variables Reference
|
|
1671
1746
|
|
|
1672
|
-
| Variable | Default | Description
|
|
1673
|
-
| ----------------------------------- | --------- |
|
|
1674
|
-
| `ROTIFEX_PORT` | `
|
|
1675
|
-
| `ROTIFEX_HOST` | `0.0.0.0` | Bind address
|
|
1676
|
-
| `ROTIFEX_CORS_ORIGIN` | `*` | Allowed CORS origin
|
|
1677
|
-
| `ROTIFEX_RATE_LIMIT_MAX` | `100` | Max requests per rate-limit window
|
|
1678
|
-
| `ROTIFEX_LOG_LEVEL` | `info` | Log level (`info`, `debug`, `warn`, `error`)
|
|
1679
|
-
| `ROTIFEX_STORAGE_MAX_FILE_SIZE_MB` | `10` | Max upload size in MB
|
|
1680
|
-
| `ROTIFEX_STORAGE_SIGNED_URL_SECRET` | auto | HMAC secret for signed file URLs
|
|
1681
|
-
| `
|
|
1682
|
-
| `
|
|
1747
|
+
| Variable | Default | Description |
|
|
1748
|
+
| ----------------------------------- | --------- | ------------------------------------------------------------------ |
|
|
1749
|
+
| `ROTIFEX_PORT` | `4994` | TCP port (auto-tries 4994 → 4995 → 4996 if unset) |
|
|
1750
|
+
| `ROTIFEX_HOST` | `0.0.0.0` | Bind address |
|
|
1751
|
+
| `ROTIFEX_CORS_ORIGIN` | `*` | Allowed CORS origin |
|
|
1752
|
+
| `ROTIFEX_RATE_LIMIT_MAX` | `100` | Max requests per rate-limit window |
|
|
1753
|
+
| `ROTIFEX_LOG_LEVEL` | `info` | Log level (`info`, `debug`, `warn`, `error`) |
|
|
1754
|
+
| `ROTIFEX_STORAGE_MAX_FILE_SIZE_MB` | `10` | Max upload size in MB |
|
|
1755
|
+
| `ROTIFEX_STORAGE_SIGNED_URL_SECRET` | auto | HMAC secret for signed file URLs |
|
|
1756
|
+
| `ROTIFEX_ACCESS_TOKEN_TTL` | `60` | Access token TTL in minutes (min 5) |
|
|
1757
|
+
| `ROTIFEX_REFRESH_TOKEN_TTL` | `43200` | Refresh token TTL in minutes (min 120, must be ≥ 2× access TTL) |
|
|
1758
|
+
| `JWT_SECRET` | auto | Access token signing secret |
|
|
1759
|
+
| `JWT_REFRESH_SECRET` | auto | Refresh token signing secret |
|
|
1683
1760
|
|
|
1684
1761
|
> `JWT_SECRET`, `JWT_REFRESH_SECRET`, and `ROTIFEX_STORAGE_SIGNED_URL_SECRET` are auto-generated on first startup if absent and saved to `.env`.
|
|
1685
1762
|
|
|
1686
1763
|
### Example `.env`
|
|
1687
1764
|
|
|
1688
1765
|
```env
|
|
1689
|
-
ROTIFEX_PORT=
|
|
1766
|
+
ROTIFEX_PORT=4994
|
|
1690
1767
|
ROTIFEX_HOST=0.0.0.0
|
|
1691
1768
|
ROTIFEX_CORS_ORIGIN=https://myapp.com
|
|
1692
1769
|
ROTIFEX_RATE_LIMIT_MAX=200
|
|
1693
1770
|
ROTIFEX_LOG_LEVEL=info
|
|
1694
1771
|
ROTIFEX_STORAGE_MAX_FILE_SIZE_MB=25
|
|
1772
|
+
ROTIFEX_ACCESS_TOKEN_TTL=60
|
|
1773
|
+
ROTIFEX_REFRESH_TOKEN_TTL=43200
|
|
1695
1774
|
JWT_SECRET=replace-with-a-long-random-string
|
|
1696
1775
|
JWT_REFRESH_SECRET=replace-with-another-long-random-string
|
|
1697
1776
|
ROTIFEX_STORAGE_SIGNED_URL_SECRET=replace-with-yet-another-secret
|
|
@@ -1717,10 +1796,10 @@ npm install
|
|
|
1717
1796
|
### Running in Development
|
|
1718
1797
|
|
|
1719
1798
|
```bash
|
|
1720
|
-
# Default port
|
|
1799
|
+
# Default port 4994 (falls back to 4995, 4996 if in use)
|
|
1721
1800
|
npx rotifex start
|
|
1722
1801
|
|
|
1723
|
-
#
|
|
1802
|
+
# Pin a specific port (skips auto-fallback)
|
|
1724
1803
|
npx rotifex start --port 4000
|
|
1725
1804
|
|
|
1726
1805
|
# Custom host
|
|
@@ -1772,7 +1851,7 @@ server {
|
|
|
1772
1851
|
server_name api.yourapp.com;
|
|
1773
1852
|
|
|
1774
1853
|
location / {
|
|
1775
|
-
proxy_pass http://127.0.0.1:
|
|
1854
|
+
proxy_pass http://127.0.0.1:4994;
|
|
1776
1855
|
proxy_set_header Host $host;
|
|
1777
1856
|
proxy_set_header X-Real-IP $remote_addr;
|
|
1778
1857
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
@@ -1795,12 +1874,12 @@ cp rotifex.db rotifex.db.backup
|
|
|
1795
1874
|
|
|
1796
1875
|
```bash
|
|
1797
1876
|
# Register
|
|
1798
|
-
curl -X POST http://localhost:
|
|
1877
|
+
curl -X POST http://localhost:4994/auth/register \
|
|
1799
1878
|
-H "Content-Type: application/json" \
|
|
1800
1879
|
-d '{"email":"jane@example.com","password":"secure123","display_name":"Jane"}'
|
|
1801
1880
|
|
|
1802
1881
|
# Login — save the returned accessToken
|
|
1803
|
-
curl -X POST http://localhost:
|
|
1882
|
+
curl -X POST http://localhost:4994/auth/login \
|
|
1804
1883
|
-H "Content-Type: application/json" \
|
|
1805
1884
|
-d '{"email":"jane@example.com","password":"secure123"}'
|
|
1806
1885
|
|
|
@@ -1814,7 +1893,7 @@ export TOKEN="<accessToken from response>"
|
|
|
1814
1893
|
|
|
1815
1894
|
```bash
|
|
1816
1895
|
# 1. Create model (admin role required)
|
|
1817
|
-
curl -X POST http://localhost:
|
|
1896
|
+
curl -X POST http://localhost:4994/admin/api/schema \
|
|
1818
1897
|
-H "Content-Type: application/json" \
|
|
1819
1898
|
-H "Authorization: Bearer $TOKEN" \
|
|
1820
1899
|
-d '{
|
|
@@ -1827,23 +1906,23 @@ curl -X POST http://localhost:3000/admin/api/schema \
|
|
|
1827
1906
|
}'
|
|
1828
1907
|
|
|
1829
1908
|
# 2. Create a record
|
|
1830
|
-
curl -X POST http://localhost:
|
|
1909
|
+
curl -X POST http://localhost:4994/api/products \
|
|
1831
1910
|
-H "Content-Type: application/json" \
|
|
1832
1911
|
-d '{"name":"Widget","price":9.99,"in_stock":true}'
|
|
1833
1912
|
|
|
1834
1913
|
# 3. List with sort and filter
|
|
1835
|
-
curl "http://localhost:
|
|
1914
|
+
curl "http://localhost:4994/api/products?sort=price&order=ASC&in_stock=1"
|
|
1836
1915
|
|
|
1837
1916
|
# 4. Get one
|
|
1838
|
-
curl http://localhost:
|
|
1917
|
+
curl http://localhost:4994/api/products/<id>
|
|
1839
1918
|
|
|
1840
1919
|
# 5. Update
|
|
1841
|
-
curl -X PUT http://localhost:
|
|
1920
|
+
curl -X PUT http://localhost:4994/api/products/<id> \
|
|
1842
1921
|
-H "Content-Type: application/json" \
|
|
1843
1922
|
-d '{"price":14.99}'
|
|
1844
1923
|
|
|
1845
1924
|
# 6. Delete
|
|
1846
|
-
curl -X DELETE http://localhost:
|
|
1925
|
+
curl -X DELETE http://localhost:4994/api/products/<id>
|
|
1847
1926
|
```
|
|
1848
1927
|
|
|
1849
1928
|
---
|
|
@@ -1852,22 +1931,22 @@ curl -X DELETE http://localhost:3000/api/products/<id>
|
|
|
1852
1931
|
|
|
1853
1932
|
```bash
|
|
1854
1933
|
# Upload a public file
|
|
1855
|
-
curl -X POST http://localhost:
|
|
1934
|
+
curl -X POST http://localhost:4994/files/upload \
|
|
1856
1935
|
-H "Authorization: Bearer $TOKEN" \
|
|
1857
1936
|
-F "file=@/path/to/photo.jpg" \
|
|
1858
1937
|
-F "visibility=public"
|
|
1859
1938
|
|
|
1860
1939
|
# Download public file (no auth needed)
|
|
1861
|
-
curl http://localhost:
|
|
1940
|
+
curl http://localhost:4994/files/<id>/download -o photo.jpg
|
|
1862
1941
|
|
|
1863
1942
|
# Upload a private file
|
|
1864
|
-
curl -X POST http://localhost:
|
|
1943
|
+
curl -X POST http://localhost:4994/files/upload \
|
|
1865
1944
|
-H "Authorization: Bearer $TOKEN" \
|
|
1866
1945
|
-F "file=@/path/to/document.pdf" \
|
|
1867
1946
|
-F "visibility=private"
|
|
1868
1947
|
|
|
1869
1948
|
# Get a signed URL for the private file
|
|
1870
|
-
curl http://localhost:
|
|
1949
|
+
curl http://localhost:4994/files/<id>/signed-url \
|
|
1871
1950
|
-H "Authorization: Bearer $TOKEN"
|
|
1872
1951
|
|
|
1873
1952
|
# Download using the signed URL
|
|
@@ -1880,7 +1959,7 @@ curl "<signed-url>" -o document.pdf
|
|
|
1880
1959
|
|
|
1881
1960
|
```bash
|
|
1882
1961
|
# Generate a completion
|
|
1883
|
-
curl -X POST http://localhost:
|
|
1962
|
+
curl -X POST http://localhost:4994/api/ai/generate \
|
|
1884
1963
|
-H "Content-Type: application/json" \
|
|
1885
1964
|
-d '{
|
|
1886
1965
|
"provider": "openai",
|
|
@@ -1890,7 +1969,7 @@ curl -X POST http://localhost:3000/api/ai/generate \
|
|
|
1890
1969
|
}'
|
|
1891
1970
|
|
|
1892
1971
|
# Multi-turn chat
|
|
1893
|
-
curl -X POST http://localhost:
|
|
1972
|
+
curl -X POST http://localhost:4994/api/ai/chat \
|
|
1894
1973
|
-H "Content-Type: application/json" \
|
|
1895
1974
|
-d '{
|
|
1896
1975
|
"provider": "anthropic",
|
|
@@ -1907,7 +1986,7 @@ curl -X POST http://localhost:3000/api/ai/chat \
|
|
|
1907
1986
|
|
|
1908
1987
|
```bash
|
|
1909
1988
|
# 1. Create an agent (admin required)
|
|
1910
|
-
curl -X POST http://localhost:
|
|
1989
|
+
curl -X POST http://localhost:4994/admin/api/agents \
|
|
1911
1990
|
-H "Content-Type: application/json" \
|
|
1912
1991
|
-H "Authorization: Bearer $TOKEN" \
|
|
1913
1992
|
-d '{
|
|
@@ -1921,7 +2000,7 @@ curl -X POST http://localhost:3000/admin/api/agents \
|
|
|
1921
2000
|
}'
|
|
1922
2001
|
|
|
1923
2002
|
# 2. Run the agent
|
|
1924
|
-
curl -X POST http://localhost:
|
|
2003
|
+
curl -X POST http://localhost:4994/api/agents/<id>/run \
|
|
1925
2004
|
-H "Content-Type: application/json" \
|
|
1926
2005
|
-d '{"input": "What is (144 / 12) * 7.5 plus 33?"}'
|
|
1927
2006
|
```
|
|
@@ -1931,7 +2010,7 @@ curl -X POST http://localhost:3000/api/agents/<id>/run \
|
|
|
1931
2010
|
### Refreshing an Expired Token
|
|
1932
2011
|
|
|
1933
2012
|
```bash
|
|
1934
|
-
curl -X POST http://localhost:
|
|
2013
|
+
curl -X POST http://localhost:4994/auth/refresh \
|
|
1935
2014
|
-H "Content-Type: application/json" \
|
|
1936
2015
|
-d '{"refreshToken":"<your-refresh-token>"}'
|
|
1937
2016
|
```
|
package/config.default.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rotifex",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Rotifex — a modern CLI toolkit for project scaffolding, development, and migrations.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,10 +21,29 @@
|
|
|
21
21
|
"postinstall": "npm rebuild better-sqlite3"
|
|
22
22
|
},
|
|
23
23
|
"keywords": [
|
|
24
|
-
"cli",
|
|
25
24
|
"rotifex",
|
|
26
|
-
"
|
|
27
|
-
"
|
|
25
|
+
"backend-as-a-service",
|
|
26
|
+
"baas",
|
|
27
|
+
"self-hosted-backend",
|
|
28
|
+
"api-generator",
|
|
29
|
+
"rest-api-generator",
|
|
30
|
+
"dynamic-api",
|
|
31
|
+
"schema-driven-api",
|
|
32
|
+
"fastify",
|
|
33
|
+
"nodejs-backend",
|
|
34
|
+
"low-code-backend",
|
|
35
|
+
"no-code-backend",
|
|
36
|
+
"sqlite-backend",
|
|
37
|
+
"admin-dashboard",
|
|
38
|
+
"jwt-authentication",
|
|
39
|
+
"file-storage",
|
|
40
|
+
"ai-platform",
|
|
41
|
+
"llm-platform",
|
|
42
|
+
"ai-agents",
|
|
43
|
+
"developer-tools",
|
|
44
|
+
"supabase-alternative",
|
|
45
|
+
"firebase-alternative",
|
|
46
|
+
"backend-framework"
|
|
28
47
|
],
|
|
29
48
|
"author": "",
|
|
30
49
|
"license": "MIT",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { registerUser, loginUser, refreshTokens, getCurrentUser, validateRegistrationInput, verifyAccessToken } from './auth.service.js';
|
|
1
|
+
import { registerUser, loginUser, refreshTokens, logout, changePassword, getCurrentUser, validateRegistrationInput, verifyAccessToken } from './auth.service.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Returns request handlers bound to the given `db` instance.
|
|
@@ -79,6 +79,55 @@ export function makeAuthController(db) {
|
|
|
79
79
|
}
|
|
80
80
|
},
|
|
81
81
|
|
|
82
|
+
async logout(request, reply) {
|
|
83
|
+
const { refreshToken } = request.body ?? {};
|
|
84
|
+
await logout(db, refreshToken);
|
|
85
|
+
return reply.status(204).send();
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
async changePassword(request, reply) {
|
|
89
|
+
// /auth/* is skipped by the JWT middleware — verify the token manually.
|
|
90
|
+
const header = request.headers['authorization'] ?? '';
|
|
91
|
+
if (!header.startsWith('Bearer ')) {
|
|
92
|
+
return reply.status(401).send({
|
|
93
|
+
error: 'Unauthorized',
|
|
94
|
+
message: 'Authorization: Bearer <token> header is required',
|
|
95
|
+
statusCode: 401,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let payload;
|
|
100
|
+
try {
|
|
101
|
+
payload = verifyAccessToken(header.slice(7));
|
|
102
|
+
} catch {
|
|
103
|
+
return reply.status(401).send({
|
|
104
|
+
error: 'Unauthorized',
|
|
105
|
+
message: 'Invalid or expired token',
|
|
106
|
+
statusCode: 401,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const { currentPassword, newPassword } = request.body ?? {};
|
|
111
|
+
if (!currentPassword || !newPassword) {
|
|
112
|
+
return reply.status(400).send({
|
|
113
|
+
error: 'Bad Request',
|
|
114
|
+
message: 'currentPassword and newPassword are required',
|
|
115
|
+
statusCode: 400,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
await changePassword(db, payload.userId, { currentPassword, newPassword });
|
|
121
|
+
return reply.status(204).send();
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return reply.status(e.statusCode ?? 500).send({
|
|
124
|
+
error: 'Password change failed',
|
|
125
|
+
message: e.message,
|
|
126
|
+
statusCode: e.statusCode ?? 500,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
|
|
82
131
|
async me(request, reply) {
|
|
83
132
|
// /auth/me is inside the /auth/* skip zone of the JWT middleware,
|
|
84
133
|
// so we verify the token manually here.
|
package/src/auth/auth.routes.js
CHANGED
|
@@ -16,8 +16,10 @@ import { makeAuthController } from './auth.controller.js';
|
|
|
16
16
|
export async function authRoutes(app, { db }) {
|
|
17
17
|
const ctrl = makeAuthController(db);
|
|
18
18
|
|
|
19
|
-
app.post('/auth/register',
|
|
20
|
-
app.post('/auth/login',
|
|
21
|
-
app.post('/auth/refresh',
|
|
22
|
-
app.
|
|
19
|
+
app.post('/auth/register', (req, reply) => ctrl.register(req, reply));
|
|
20
|
+
app.post('/auth/login', (req, reply) => ctrl.login(req, reply));
|
|
21
|
+
app.post('/auth/refresh', (req, reply) => ctrl.refresh(req, reply));
|
|
22
|
+
app.post('/auth/logout', (req, reply) => ctrl.logout(req, reply));
|
|
23
|
+
app.post('/auth/change-password', (req, reply) => ctrl.changePassword(req, reply));
|
|
24
|
+
app.get('/auth/me', (req, reply) => ctrl.me(req, reply));
|
|
23
25
|
}
|
package/src/auth/auth.service.js
CHANGED
|
@@ -41,15 +41,31 @@ const jwtRefreshSecret = () => (_jwtRefreshSecret ??= resolveSecret('JWT_REFRESH
|
|
|
41
41
|
|
|
42
42
|
// ── Token helpers ─────────────────────────────────────────────────────────────
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
const
|
|
44
|
+
// Thresholds enforced by both the server and the settings UI.
|
|
45
|
+
export const ACCESS_TTL_MIN_MINUTES = 5;
|
|
46
|
+
export const REFRESH_TTL_MIN_MINUTES = 120; // 2 h
|
|
47
|
+
export const REFRESH_TTL_MULTIPLIER = 2; // refresh must be >= 2× access
|
|
48
|
+
|
|
49
|
+
function getAccessTTL() {
|
|
50
|
+
const minutes = Number(process.env.ROTIFEX_ACCESS_TOKEN_TTL) || 60;
|
|
51
|
+
return `${Math.max(minutes, ACCESS_TTL_MIN_MINUTES)}m`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getRefreshTTL() {
|
|
55
|
+
const accessMinutes = Number(process.env.ROTIFEX_ACCESS_TOKEN_TTL) || 60;
|
|
56
|
+
const refreshMinutes = Number(process.env.ROTIFEX_REFRESH_TOKEN_TTL) || 43200;
|
|
57
|
+
const minRefresh = Math.max(REFRESH_TTL_MIN_MINUTES, accessMinutes * REFRESH_TTL_MULTIPLIER);
|
|
58
|
+
return `${Math.max(refreshMinutes, minRefresh)}m`;
|
|
59
|
+
}
|
|
46
60
|
|
|
47
61
|
export function signAccessToken(payload) {
|
|
48
|
-
return jwt.sign(payload, jwtSecret(), { expiresIn:
|
|
62
|
+
return jwt.sign(payload, jwtSecret(), { expiresIn: getAccessTTL() });
|
|
49
63
|
}
|
|
50
64
|
|
|
51
65
|
export function signRefreshToken(payload) {
|
|
52
|
-
|
|
66
|
+
// jti (JWT ID) uniquely identifies this token so it can be individually revoked.
|
|
67
|
+
const jti = crypto.randomUUID();
|
|
68
|
+
return jwt.sign({ ...payload, jti }, jwtRefreshSecret(), { expiresIn: getRefreshTTL() });
|
|
53
69
|
}
|
|
54
70
|
|
|
55
71
|
export function verifyAccessToken(token) {
|
|
@@ -74,6 +90,32 @@ export function ensureAuthSchema(db) {
|
|
|
74
90
|
}
|
|
75
91
|
}
|
|
76
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Ensure the `_revoked_tokens` table exists for refresh token revocation.
|
|
95
|
+
*/
|
|
96
|
+
export function ensureTokenBlacklist(db) {
|
|
97
|
+
db.exec(`
|
|
98
|
+
CREATE TABLE IF NOT EXISTS _revoked_tokens (
|
|
99
|
+
jti TEXT PRIMARY KEY,
|
|
100
|
+
expires_at TEXT NOT NULL
|
|
101
|
+
);
|
|
102
|
+
`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function revokeToken(db, jti, expiresAt) {
|
|
106
|
+
try {
|
|
107
|
+
db.run('INSERT OR IGNORE INTO _revoked_tokens (jti, expires_at) VALUES (?, ?)', [jti, expiresAt]);
|
|
108
|
+
} catch {
|
|
109
|
+
// ignore
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isTokenRevoked(db, jti) {
|
|
114
|
+
if (!jti) return false;
|
|
115
|
+
const row = db.get('SELECT jti FROM _revoked_tokens WHERE jti = ?', [jti]);
|
|
116
|
+
return !!row;
|
|
117
|
+
}
|
|
118
|
+
|
|
77
119
|
// ── Input validation ──────────────────────────────────────────────────────────
|
|
78
120
|
|
|
79
121
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
@@ -167,6 +209,13 @@ export async function refreshTokens(db, refreshToken) {
|
|
|
167
209
|
throw err;
|
|
168
210
|
}
|
|
169
211
|
|
|
212
|
+
// Reject if this specific token has been revoked (logout / rotation).
|
|
213
|
+
if (payload.jti && isTokenRevoked(db, payload.jti)) {
|
|
214
|
+
const err = new Error('Refresh token has been revoked');
|
|
215
|
+
err.statusCode = 401;
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
|
|
170
219
|
// Re-fetch user so role changes are reflected in the new access token.
|
|
171
220
|
const user = db.get('SELECT id, role FROM users WHERE id = ?', [payload.userId]);
|
|
172
221
|
if (!user) {
|
|
@@ -175,8 +224,67 @@ export async function refreshTokens(db, refreshToken) {
|
|
|
175
224
|
throw err;
|
|
176
225
|
}
|
|
177
226
|
|
|
227
|
+
// Revoke the consumed refresh token (token rotation — each token is one-use).
|
|
228
|
+
if (payload.jti) {
|
|
229
|
+
revokeToken(db, payload.jti, new Date(payload.exp * 1000).toISOString());
|
|
230
|
+
}
|
|
231
|
+
|
|
178
232
|
const newAccessToken = signAccessToken({ userId: user.id, role: user.role });
|
|
179
233
|
const newRefreshToken = signRefreshToken({ userId: user.id, role: user.role });
|
|
180
234
|
|
|
181
235
|
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
|
|
182
236
|
}
|
|
237
|
+
|
|
238
|
+
export async function logout(db, refreshToken) {
|
|
239
|
+
if (!refreshToken) return;
|
|
240
|
+
try {
|
|
241
|
+
const payload = verifyRefreshToken(refreshToken);
|
|
242
|
+
if (payload.jti) {
|
|
243
|
+
revokeToken(db, payload.jti, new Date(payload.exp * 1000).toISOString());
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
// Token already invalid/expired — nothing to revoke, treat as success.
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function changePassword(db, userId, { currentPassword, newPassword }) {
|
|
251
|
+
const user = db.get('SELECT * FROM users WHERE id = ?', [userId]);
|
|
252
|
+
if (!user) {
|
|
253
|
+
const err = new Error('User not found');
|
|
254
|
+
err.statusCode = 404;
|
|
255
|
+
throw err;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!user.password_hash) {
|
|
259
|
+
const err = new Error('No password set for this account');
|
|
260
|
+
err.statusCode = 400;
|
|
261
|
+
throw err;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const valid = await verifyPassword(currentPassword, user.password_hash);
|
|
265
|
+
if (!valid) {
|
|
266
|
+
const err = new Error('Current password is incorrect');
|
|
267
|
+
err.statusCode = 401;
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!newPassword || newPassword.length < 8) {
|
|
272
|
+
const err = new Error('New password must be at least 8 characters');
|
|
273
|
+
err.statusCode = 400;
|
|
274
|
+
throw err;
|
|
275
|
+
}
|
|
276
|
+
if (!/[a-zA-Z]/.test(newPassword)) {
|
|
277
|
+
const err = new Error('New password must contain at least one letter');
|
|
278
|
+
err.statusCode = 400;
|
|
279
|
+
throw err;
|
|
280
|
+
}
|
|
281
|
+
if (!/[0-9]/.test(newPassword)) {
|
|
282
|
+
const err = new Error('New password must contain at least one number');
|
|
283
|
+
err.statusCode = 400;
|
|
284
|
+
throw err;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const password_hash = await hashPassword(newPassword);
|
|
288
|
+
const now = new Date().toISOString();
|
|
289
|
+
db.run('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?', [password_hash, now, userId]);
|
|
290
|
+
}
|
package/src/commands/start.js
CHANGED
|
@@ -1,8 +1,39 @@
|
|
|
1
1
|
import { execSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import { createServer as createNetServer } from 'node:net';
|
|
2
3
|
import { logger } from '../lib/logger.js';
|
|
3
4
|
import { loadConfig } from '../lib/config.js';
|
|
4
5
|
import { createServer } from '../server/index.js';
|
|
5
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Check whether a TCP port is available on the given host.
|
|
9
|
+
* Resolves true if the port can be bound, false if it's already in use.
|
|
10
|
+
*/
|
|
11
|
+
function isPortAvailable(port, host) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const probe = createNetServer();
|
|
14
|
+
probe.once('error', () => resolve(false));
|
|
15
|
+
probe.once('listening', () => { probe.close(); resolve(true); });
|
|
16
|
+
probe.listen(port, host);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Starting from `basePort`, find the first available port within `maxTries`.
|
|
22
|
+
* Logs a warning for each skipped port.
|
|
23
|
+
* Throws if no port is found within the range.
|
|
24
|
+
*/
|
|
25
|
+
async function findAvailablePort(basePort, host, maxTries = 3) {
|
|
26
|
+
for (let i = 0; i < maxTries; i++) {
|
|
27
|
+
const port = basePort + i;
|
|
28
|
+
if (await isPortAvailable(port, host)) return port;
|
|
29
|
+
logger.warn(`Port ${port} is already in use${i < maxTries - 1 ? `, trying ${port + 1}…` : '.'}`);
|
|
30
|
+
}
|
|
31
|
+
throw new Error(
|
|
32
|
+
`All ports ${basePort}–${basePort + maxTries - 1} are in use. ` +
|
|
33
|
+
`Free one up or set a custom port with --port or ROTIFEX_PORT.`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
6
37
|
/**
|
|
7
38
|
* Try to load better-sqlite3. If the native addon is stale or compiled for
|
|
8
39
|
* a different platform/Node version, rebuild it and re-exec this process.
|
|
@@ -66,6 +97,15 @@ export function registerStartCommand(program) {
|
|
|
66
97
|
|
|
67
98
|
const config = loadConfig({ cliOverrides });
|
|
68
99
|
|
|
100
|
+
// ── Port resolution ───────────────────────────────────────────
|
|
101
|
+
// If the user explicitly set a port (--port flag or ROTIFEX_PORT env var)
|
|
102
|
+
// respect it exactly and let Fastify fail naturally if it's taken.
|
|
103
|
+
// Otherwise try the base port then fall through 4994 → 4995 → 4996.
|
|
104
|
+
const portExplicit = !!options.port || !!process.env.ROTIFEX_PORT;
|
|
105
|
+
if (!portExplicit) {
|
|
106
|
+
config.server.port = await findAvailablePort(config.server.port, config.server.host);
|
|
107
|
+
}
|
|
108
|
+
|
|
69
109
|
// ── Create & start server ─────────────────────────────────────
|
|
70
110
|
const app = await createServer(config);
|
|
71
111
|
|
package/src/engine/tableSync.js
CHANGED
|
@@ -3,7 +3,9 @@ import { createTable } from '../db/index.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* Synchronise DB tables with the loaded schema.
|
|
5
5
|
*
|
|
6
|
-
* Uses `CREATE TABLE IF NOT EXISTS
|
|
6
|
+
* Uses `CREATE TABLE IF NOT EXISTS` for initial creation, then adds any
|
|
7
|
+
* columns that are present in the schema but missing from the existing table
|
|
8
|
+
* (safe schema evolution without data loss).
|
|
7
9
|
*
|
|
8
10
|
* @param {import('../db/adapters/base.js').DatabaseAdapter} db
|
|
9
11
|
* @param {Map<string, { tableName: string, fields: object[] }>} models
|
|
@@ -24,5 +26,36 @@ export function syncTables(db, models) {
|
|
|
24
26
|
});
|
|
25
27
|
|
|
26
28
|
createTable(db, model.tableName, columns);
|
|
29
|
+
addMissingColumns(db, model.tableName, model.fields);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* For an already-existing table, ALTER TABLE to add any columns that are in
|
|
35
|
+
* the schema definition but not yet in the DB.
|
|
36
|
+
*
|
|
37
|
+
* SQLite does not allow adding NOT NULL columns without a DEFAULT to existing
|
|
38
|
+
* tables (existing rows would violate the constraint), so we omit NOT NULL
|
|
39
|
+
* for added columns. The application layer handles required-field validation.
|
|
40
|
+
*/
|
|
41
|
+
function addMissingColumns(db, tableName, fields) {
|
|
42
|
+
const existing = new Set(
|
|
43
|
+
db.all(`PRAGMA table_info(${tableName})`).map(r => r.name),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
for (const f of fields) {
|
|
47
|
+
if (existing.has(f.name)) continue;
|
|
48
|
+
|
|
49
|
+
let colDef = `${f.name} ${f.sqlType}`;
|
|
50
|
+
if (f.default !== undefined) {
|
|
51
|
+
const val = typeof f.default === 'string' ? `'${f.default}'` : f.default;
|
|
52
|
+
colDef += ` DEFAULT ${val}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${colDef}`);
|
|
57
|
+
} catch {
|
|
58
|
+
// Column added by a concurrent call — safe to ignore.
|
|
59
|
+
}
|
|
27
60
|
}
|
|
28
61
|
}
|
package/src/lib/config.js
CHANGED
|
@@ -90,10 +90,12 @@ function applyEnvOverrides(config) {
|
|
|
90
90
|
config.storage.signedUrlSecret = env.ROTIFEX_STORAGE_SIGNED_URL_SECRET;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
// JWT auth secrets — read directly by auth.service.js via
|
|
94
|
-
// exposed here only so config introspection tools can see them.
|
|
95
|
-
if (env.JWT_SECRET)
|
|
96
|
-
if (env.JWT_REFRESH_SECRET)
|
|
93
|
+
// JWT auth secrets and token TTLs — read directly by auth.service.js via
|
|
94
|
+
// process.env, exposed here only so config introspection tools can see them.
|
|
95
|
+
if (env.JWT_SECRET) { config.auth = config.auth || {}; config.auth.jwtSecret = env.JWT_SECRET; }
|
|
96
|
+
if (env.JWT_REFRESH_SECRET) { config.auth = config.auth || {}; config.auth.jwtRefreshSecret = env.JWT_REFRESH_SECRET; }
|
|
97
|
+
if (env.ROTIFEX_ACCESS_TOKEN_TTL) { config.auth = config.auth || {}; config.auth.accessTokenTTL = Number(env.ROTIFEX_ACCESS_TOKEN_TTL); }
|
|
98
|
+
if (env.ROTIFEX_REFRESH_TOKEN_TTL) { config.auth = config.auth || {}; config.auth.refreshTokenTTL = Number(env.ROTIFEX_REFRESH_TOKEN_TTL); }
|
|
97
99
|
|
|
98
100
|
return config;
|
|
99
101
|
}
|
|
@@ -7,6 +7,8 @@ import { getUsageSummary } from '../../ai/ai.usage.js';
|
|
|
7
7
|
import { upsertModel, removeModel } from '../../engine/schemaStore.js';
|
|
8
8
|
import { syncTables } from '../../engine/tableSync.js';
|
|
9
9
|
import { getLogs } from '../../lib/logBuffer.js';
|
|
10
|
+
import { registerUser, validateRegistrationInput } from '../../auth/auth.service.js';
|
|
11
|
+
import { hashPassword } from '../../auth/password.util.js';
|
|
10
12
|
|
|
11
13
|
// ── .env file helpers ─────────────────────────────────────────────────────────
|
|
12
14
|
|
|
@@ -226,6 +228,52 @@ export async function adminRoutes(app, { db }) {
|
|
|
226
228
|
return reply.status(204).send();
|
|
227
229
|
});
|
|
228
230
|
|
|
231
|
+
// ── POST /admin/api/users — create user with hashed password ──────
|
|
232
|
+
app.post('/admin/api/users', async (request, reply) => {
|
|
233
|
+
const { email, password, display_name, role } = request.body || {};
|
|
234
|
+
|
|
235
|
+
const errors = validateRegistrationInput({ email: email ?? '', password: password ?? '' });
|
|
236
|
+
if (errors.length) {
|
|
237
|
+
return reply.status(400).send({ error: 'Validation Error', message: errors, statusCode: 400 });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const user = await registerUser(db, { email, password, display_name, role: role || 'user' });
|
|
242
|
+
return reply.status(201).send({ data: user, message: 'User created successfully' });
|
|
243
|
+
} catch (e) {
|
|
244
|
+
return reply.status(e.statusCode ?? 500).send({
|
|
245
|
+
error: 'User creation failed',
|
|
246
|
+
message: e.message,
|
|
247
|
+
statusCode: e.statusCode ?? 500,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// ── PUT /admin/api/users/:id/password — admin reset a user's password ─
|
|
253
|
+
app.put('/admin/api/users/:id/password', async (request, reply) => {
|
|
254
|
+
const { id } = request.params;
|
|
255
|
+
const { password } = request.body || {};
|
|
256
|
+
|
|
257
|
+
if (!password || password.length < 8) {
|
|
258
|
+
return reply.status(400).send({
|
|
259
|
+
error: 'Bad Request',
|
|
260
|
+
message: 'Password must be at least 8 characters',
|
|
261
|
+
statusCode: 400,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const user = db.get('SELECT id FROM users WHERE id = ?', [id]);
|
|
266
|
+
if (!user) {
|
|
267
|
+
return reply.status(404).send({ error: 'Not Found', message: 'User not found', statusCode: 404 });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const password_hash = await hashPassword(password);
|
|
271
|
+
const now = new Date().toISOString();
|
|
272
|
+
db.run('UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?', [password_hash, now, id]);
|
|
273
|
+
|
|
274
|
+
return reply.status(204).send();
|
|
275
|
+
});
|
|
276
|
+
|
|
229
277
|
// ── GET /admin/api/env — read current .env values ─────────────────
|
|
230
278
|
app.get('/admin/api/env', () => {
|
|
231
279
|
const fileVars = readEnvFile();
|
|
@@ -266,6 +314,8 @@ export async function adminRoutes(app, { db }) {
|
|
|
266
314
|
const ENV_KEYS = [
|
|
267
315
|
'JWT_SECRET',
|
|
268
316
|
'JWT_REFRESH_SECRET',
|
|
317
|
+
'ROTIFEX_ACCESS_TOKEN_TTL',
|
|
318
|
+
'ROTIFEX_REFRESH_TOKEN_TTL',
|
|
269
319
|
'ROTIFEX_PORT',
|
|
270
320
|
'ROTIFEX_HOST',
|
|
271
321
|
'ROTIFEX_CORS_ORIGIN',
|
package/src/server/server.js
CHANGED
|
@@ -10,7 +10,7 @@ import { healthRoutes } from './routes/health.js';
|
|
|
10
10
|
import { fileRoutes } from './routes/files.js';
|
|
11
11
|
import { adminRoutes } from './routes/admin.js';
|
|
12
12
|
import { authRoutes } from '../auth/auth.routes.js';
|
|
13
|
-
import { ensureAuthSchema } from '../auth/auth.service.js';
|
|
13
|
+
import { ensureAuthSchema, ensureTokenBlacklist } from '../auth/auth.service.js';
|
|
14
14
|
import { aiRoutes } from '../ai/ai.routes.js';
|
|
15
15
|
import { agentRoutes } from '../ai/agent.routes.js';
|
|
16
16
|
import { registerJwtMiddleware } from '../auth/jwt.middleware.js';
|
|
@@ -73,6 +73,8 @@ export async function createServer(config) {
|
|
|
73
73
|
|
|
74
74
|
// Ensure password_hash column exists on the users table now that it's created.
|
|
75
75
|
ensureAuthSchema(db);
|
|
76
|
+
// Ensure the refresh token blacklist table exists.
|
|
77
|
+
ensureTokenBlacklist(db);
|
|
76
78
|
|
|
77
79
|
// ── File Storage ────────────────────────────────────────────────────
|
|
78
80
|
const storage = new StorageManager(db, config.storage);
|