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 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 (1 hour) and long-lived refresh token (30 days).
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 without requiring the password.
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:3000
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
- | `401` | Invalid or expired refresh token |
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:3000/files/uuid/download?token=abc123&expires=1741300000",
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": "3000",
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 | Secret Env Var |
1294
- | ------------- | --------- | ------- | -------------------- |
1295
- | Access Token | HS256 | 1 hour | `JWT_SECRET` |
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
- - Admin actions on user accounts
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
- | `JWT_SECRET` | Access token signing secret |
1613
- | `JWT_REFRESH_SECRET` | Refresh token signing secret |
1614
- | `ROTIFEX_PORT` | Server port |
1615
- | `ROTIFEX_HOST` | Server bind host |
1616
- | `ROTIFEX_CORS_ORIGIN` | Allowed CORS origin(s) |
1617
- | `ROTIFEX_RATE_LIMIT_MAX` | Max requests per time window |
1618
- | `ROTIFEX_LOG_LEVEL` | Log verbosity |
1619
- | `ROTIFEX_STORAGE_MAX_FILE_SIZE_MB` | Max upload size in MB |
1620
- | `ROTIFEX_STORAGE_SIGNED_URL_SECRET` | HMAC secret for signed URLs |
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` | `3000` | TCP 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
- | `JWT_SECRET` | auto | Access token signing secret |
1682
- | `JWT_REFRESH_SECRET` | auto | Refresh token signing secret |
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=3000
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 3000
1799
+ # Default port 4994 (falls back to 4995, 4996 if in use)
1721
1800
  npx rotifex start
1722
1801
 
1723
- # Custom port
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:3000;
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:3000/auth/register \
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:3000/auth/login \
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:3000/admin/api/schema \
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:3000/api/products \
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:3000/api/products?sort=price&order=ASC&in_stock=1"
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:3000/api/products/<id>
1917
+ curl http://localhost:4994/api/products/<id>
1839
1918
 
1840
1919
  # 5. Update
1841
- curl -X PUT http://localhost:3000/api/products/<id> \
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:3000/api/products/<id>
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:3000/files/upload \
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:3000/files/<id>/download -o photo.jpg
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:3000/files/upload \
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:3000/files/<id>/signed-url \
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:3000/api/ai/generate \
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:3000/api/ai/chat \
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:3000/admin/api/agents \
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:3000/api/agents/<id>/run \
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:3000/auth/refresh \
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
  ```
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "server": {
3
3
  "host": "0.0.0.0",
4
- "port": 3000
4
+ "port": 4994
5
5
  },
6
6
  "cors": {
7
7
  "origin": "*"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rotifex",
3
- "version": "0.1.6",
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
- "scaffold",
27
- "migrate"
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.
@@ -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', (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.get('/auth/me', (req, reply) => ctrl.me(req, reply));
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
  }
@@ -41,15 +41,31 @@ const jwtRefreshSecret = () => (_jwtRefreshSecret ??= resolveSecret('JWT_REFRESH
41
41
 
42
42
  // ── Token helpers ─────────────────────────────────────────────────────────────
43
43
 
44
- const ACCESS_TTL = '1h';
45
- const REFRESH_TTL = '30d';
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: ACCESS_TTL });
62
+ return jwt.sign(payload, jwtSecret(), { expiresIn: getAccessTTL() });
49
63
  }
50
64
 
51
65
  export function signRefreshToken(payload) {
52
- return jwt.sign(payload, jwtRefreshSecret(), { expiresIn: REFRESH_TTL });
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
+ }
@@ -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
 
@@ -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`, so it's safe to call on every startup.
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 process.env,
94
- // 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; }
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',
@@ -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);