arcway 0.4.0 → 0.4.1
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/package.json +1 -1
- package/server/bin/commands/start.js +10 -1
- package/server/lib/vault/ids.js +8 -2
- package/server/lib/vault/index.js +2 -1
- package/server/pages/ssr.js +2 -1
- package/server/router/api-router.js +30 -29
- package/server/router/ratelimit.js +40 -4
- package/server/router/routes.js +17 -12
package/package.json
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import boot from '#server/boot.js';
|
|
2
|
+
import { loadEnvFiles } from '#server/env.js';
|
|
2
3
|
|
|
3
4
|
async function startServer(mode) {
|
|
4
5
|
if (mode === 'production') {
|
|
5
6
|
process.env.NODE_ENV = 'production';
|
|
6
7
|
}
|
|
7
8
|
|
|
9
|
+
// Load .env files so `arcway dev` / `arcway start` pick up local config
|
|
10
|
+
// (.env, .env.local, .env.<mode>, .env.<mode>.local). Real process env wins —
|
|
11
|
+
// dotenv never overrides already-set variables.
|
|
12
|
+
loadEnvFiles(process.cwd(), mode);
|
|
13
|
+
|
|
8
14
|
const app = await boot({ mode, rootDir: process.cwd() });
|
|
9
15
|
app.logger.info('Arcway framework ready', { mode, port: app.port });
|
|
10
16
|
|
|
@@ -24,7 +30,10 @@ async function startServer(mode) {
|
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
function register(program) {
|
|
27
|
-
program
|
|
33
|
+
program
|
|
34
|
+
.command('start')
|
|
35
|
+
.description('Start production server')
|
|
36
|
+
.action(() => startServer('production'));
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
export { startServer };
|
package/server/lib/vault/ids.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { nanoid } from 'nanoid';
|
|
2
|
+
import { customAlphabet, nanoid } from 'nanoid';
|
|
3
|
+
|
|
4
|
+
const LOWERCASE_BASE32_ALPHABET = 'abcdefghijklmnopqrstuvwxyz234567';
|
|
5
|
+
const lowercaseBase32 = customAlphabet(LOWERCASE_BASE32_ALPHABET);
|
|
3
6
|
function generateUUID() {
|
|
4
7
|
return randomUUID();
|
|
5
8
|
}
|
|
6
9
|
function generateNanoId(size) {
|
|
7
10
|
return nanoid(size);
|
|
8
11
|
}
|
|
9
|
-
|
|
12
|
+
function generateBase32Id(size = 16) {
|
|
13
|
+
return lowercaseBase32(size);
|
|
14
|
+
}
|
|
15
|
+
export { generateBase32Id, generateNanoId, generateUUID };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { hashPassword, verifyPassword } from './password.js';
|
|
2
|
-
import { generateUUID, generateNanoId } from './ids.js';
|
|
2
|
+
import { generateBase32Id, generateUUID, generateNanoId } from './ids.js';
|
|
3
3
|
import { encrypt, decrypt } from './encrypt.js';
|
|
4
4
|
import { jwtEncode, jwtDecode } from './jwt.js';
|
|
5
5
|
import {
|
|
@@ -29,6 +29,7 @@ export {
|
|
|
29
29
|
encodeMasterSecret,
|
|
30
30
|
encrypt,
|
|
31
31
|
formatVaultKey,
|
|
32
|
+
generateBase32Id,
|
|
32
33
|
generateNanoId,
|
|
33
34
|
generateUUID,
|
|
34
35
|
hashPassword,
|
package/server/pages/ssr.js
CHANGED
|
@@ -50,7 +50,8 @@ async function loadComponent(bundlePath, cacheKey, componentCache, cacheVersion)
|
|
|
50
50
|
const Component = mod.default;
|
|
51
51
|
componentCache.set(cacheKey, Component);
|
|
52
52
|
return Component;
|
|
53
|
-
} catch {
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error(`[arcway] failed to load component ${cacheKey} from ${bundlePath}:`, error && error.stack ? error.stack : error);
|
|
54
55
|
// Component failed to load (missing file, syntax error) — caller handles null
|
|
55
56
|
return null;
|
|
56
57
|
}
|
|
@@ -2,7 +2,7 @@ import { discoverRoutes, matchRoute, compilePattern, sortBySpecificity } from '.
|
|
|
2
2
|
import { discoverMiddleware, getMiddlewareForRoute, buildMiddlewareChain } from './middleware.js';
|
|
3
3
|
import { sendJson, serializeResponse } from './http-helpers.js';
|
|
4
4
|
import { ErrorCodes } from '../constants.js';
|
|
5
|
-
import {
|
|
5
|
+
import { checkRouteRateLimits } from './ratelimit.js';
|
|
6
6
|
import { sealSession, buildSessionSetCookie, buildSessionClearCookie } from '../session/index.js';
|
|
7
7
|
import { flattenHeaders } from '../session/helpers.js';
|
|
8
8
|
import { validateRequestSchema } from '../validation.js';
|
|
@@ -71,14 +71,25 @@ class ApiRouter {
|
|
|
71
71
|
return matchRoute(this._routes, method, path);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
async forward(
|
|
74
|
+
async forward(
|
|
75
|
+
{ method: fwdMethod, path: fwdPath, params: fwdParams, body: fwdBody, query: fwdQuery },
|
|
76
|
+
callerCtx,
|
|
77
|
+
) {
|
|
75
78
|
const matched = matchRoute(this._routes, fwdMethod, fwdPath);
|
|
76
79
|
if (!matched) {
|
|
77
|
-
return {
|
|
80
|
+
return {
|
|
81
|
+
status: 404,
|
|
82
|
+
error: { code: ErrorCodes.NOT_FOUND, message: `No route for ${fwdMethod} ${fwdPath}` },
|
|
83
|
+
};
|
|
78
84
|
}
|
|
79
85
|
const { route, params: matchedParams } = matched;
|
|
80
86
|
|
|
81
|
-
const mergedQuery = {
|
|
87
|
+
const mergedQuery = {
|
|
88
|
+
...(callerCtx.req?.query ?? {}),
|
|
89
|
+
...(fwdQuery ?? {}),
|
|
90
|
+
...matchedParams,
|
|
91
|
+
...(fwdParams ?? {}),
|
|
92
|
+
};
|
|
82
93
|
const reqBody = fwdBody !== undefined ? fwdBody : callerCtx.req?.body;
|
|
83
94
|
const validated = validateRequestSchema(route.config.schema, mergedQuery, reqBody);
|
|
84
95
|
if (validated.error) {
|
|
@@ -143,15 +154,10 @@ class ApiRouter {
|
|
|
143
154
|
|
|
144
155
|
async executeRoute(route, reqInfo) {
|
|
145
156
|
if (route.config._parsedRateLimit && this._redis) {
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
max,
|
|
151
|
-
windowSec,
|
|
152
|
-
});
|
|
153
|
-
if (!rl.allowed) {
|
|
154
|
-
const retryAfter = Math.max(1, rl.resetAt - Math.ceil(Date.now() / 1000));
|
|
157
|
+
const checked = await checkRouteRateLimits(this._redis.client, route.config, reqInfo.ip);
|
|
158
|
+
if (!checked.allowed) {
|
|
159
|
+
const { policy, result } = checked.selected;
|
|
160
|
+
const retryAfter = Math.max(1, result.resetAt - Math.ceil(Date.now() / 1000));
|
|
155
161
|
return {
|
|
156
162
|
status: 429,
|
|
157
163
|
error: {
|
|
@@ -159,9 +165,9 @@ class ApiRouter {
|
|
|
159
165
|
message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,
|
|
160
166
|
},
|
|
161
167
|
headers: {
|
|
162
|
-
'X-RateLimit-Limit': String(max),
|
|
168
|
+
'X-RateLimit-Limit': String(policy.max),
|
|
163
169
|
'X-RateLimit-Remaining': '0',
|
|
164
|
-
'X-RateLimit-Reset': String(
|
|
170
|
+
'X-RateLimit-Reset': String(result.resetAt),
|
|
165
171
|
'Retry-After': String(retryAfter),
|
|
166
172
|
},
|
|
167
173
|
};
|
|
@@ -268,15 +274,10 @@ class ApiRouter {
|
|
|
268
274
|
|
|
269
275
|
// ── Rate limiting ──
|
|
270
276
|
if (route.config._parsedRateLimit && this._redis) {
|
|
271
|
-
const
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
max,
|
|
276
|
-
windowSec,
|
|
277
|
-
});
|
|
278
|
-
if (!rl.allowed) {
|
|
279
|
-
const retryAfter = Math.max(1, rl.resetAt - Math.ceil(Date.now() / 1000));
|
|
277
|
+
const checked = await checkRouteRateLimits(this._redis.client, route.config, ip);
|
|
278
|
+
const { policy, result } = checked.selected;
|
|
279
|
+
if (!checked.allowed) {
|
|
280
|
+
const retryAfter = Math.max(1, result.resetAt - Math.ceil(Date.now() / 1000));
|
|
280
281
|
sendJson(
|
|
281
282
|
res,
|
|
282
283
|
429,
|
|
@@ -287,17 +288,17 @@ class ApiRouter {
|
|
|
287
288
|
},
|
|
288
289
|
},
|
|
289
290
|
{
|
|
290
|
-
'X-RateLimit-Limit': String(max),
|
|
291
|
+
'X-RateLimit-Limit': String(policy.max),
|
|
291
292
|
'X-RateLimit-Remaining': '0',
|
|
292
|
-
'X-RateLimit-Reset': String(
|
|
293
|
+
'X-RateLimit-Reset': String(result.resetAt),
|
|
293
294
|
'Retry-After': String(retryAfter),
|
|
294
295
|
},
|
|
295
296
|
);
|
|
296
297
|
return true;
|
|
297
298
|
}
|
|
298
|
-
res.setHeader('X-RateLimit-Limit', String(max));
|
|
299
|
-
res.setHeader('X-RateLimit-Remaining', String(
|
|
300
|
-
res.setHeader('X-RateLimit-Reset', String(
|
|
299
|
+
res.setHeader('X-RateLimit-Limit', String(policy.max));
|
|
300
|
+
res.setHeader('X-RateLimit-Remaining', String(result.remaining));
|
|
301
|
+
res.setHeader('X-RateLimit-Reset', String(result.resetAt));
|
|
301
302
|
}
|
|
302
303
|
|
|
303
304
|
// ── Body parsing control ──
|
|
@@ -9,11 +9,15 @@ const LIMIT_REGEX = /^(\d+)\s+per\s+(sec|min|hr|day)$/i;
|
|
|
9
9
|
|
|
10
10
|
function parseRateLimit(limitStr) {
|
|
11
11
|
if (!limitStr || typeof limitStr !== 'string') {
|
|
12
|
-
throw new Error(
|
|
12
|
+
throw new Error(
|
|
13
|
+
`Invalid rate limit: expected "N per TIMERANGE", got ${JSON.stringify(limitStr)}`,
|
|
14
|
+
);
|
|
13
15
|
}
|
|
14
16
|
const match = limitStr.match(LIMIT_REGEX);
|
|
15
17
|
if (!match) {
|
|
16
|
-
throw new Error(
|
|
18
|
+
throw new Error(
|
|
19
|
+
`Invalid rate limit: expected "N per TIMERANGE" (sec|min|hr|day), got "${limitStr}"`,
|
|
20
|
+
);
|
|
17
21
|
}
|
|
18
22
|
const max = parseInt(match[1], 10);
|
|
19
23
|
if (max <= 0) {
|
|
@@ -35,7 +39,7 @@ return {count, ttl}
|
|
|
35
39
|
`;
|
|
36
40
|
|
|
37
41
|
async function checkRateLimit(redisClient, { key, ip, max, windowSec }) {
|
|
38
|
-
const redisKey = `rl:${key}:${ip}`;
|
|
42
|
+
const redisKey = `rl:${key}:${windowSec}:${ip}`;
|
|
39
43
|
const result = await redisClient.eval(LUA_INCR_EXPIRE, 1, redisKey, windowSec);
|
|
40
44
|
const count = result[0];
|
|
41
45
|
const ttl = result[1];
|
|
@@ -47,4 +51,36 @@ async function checkRateLimit(redisClient, { key, ip, max, windowSec }) {
|
|
|
47
51
|
};
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
function configuredPolicies(config) {
|
|
55
|
+
if (Array.isArray(config._parsedRateLimits)) return config._parsedRateLimits;
|
|
56
|
+
if (!config._parsedRateLimit) return [];
|
|
57
|
+
return [{ key: config.ratelimit.key, ...config._parsedRateLimit }];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function checkRouteRateLimits(redisClient, config, ip) {
|
|
61
|
+
const policies = configuredPolicies(config);
|
|
62
|
+
const results = await Promise.all(
|
|
63
|
+
policies.map(async (policy) => ({
|
|
64
|
+
policy,
|
|
65
|
+
result: await checkRateLimit(redisClient, { ...policy, ip }),
|
|
66
|
+
})),
|
|
67
|
+
);
|
|
68
|
+
const denied = results.filter(({ result }) => !result.allowed);
|
|
69
|
+
const candidates = denied.length > 0 ? denied : results;
|
|
70
|
+
const selected = candidates.reduce((strictest, candidate) => {
|
|
71
|
+
if (!strictest) return candidate;
|
|
72
|
+
if (denied.length > 0) {
|
|
73
|
+
return candidate.result.resetAt > strictest.result.resetAt ? candidate : strictest;
|
|
74
|
+
}
|
|
75
|
+
const candidateRatio = candidate.result.remaining / candidate.policy.max;
|
|
76
|
+
const strictestRatio = strictest.result.remaining / strictest.policy.max;
|
|
77
|
+
return candidateRatio < strictestRatio ? candidate : strictest;
|
|
78
|
+
}, null);
|
|
79
|
+
return {
|
|
80
|
+
allowed: denied.length === 0,
|
|
81
|
+
selected,
|
|
82
|
+
results,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export { checkRateLimit, checkRouteRateLimits, parseRateLimit };
|
package/server/router/routes.js
CHANGED
|
@@ -60,13 +60,22 @@ function buildRoutesFromModules(entries, { prefix = '', plugin } = {}) {
|
|
|
60
60
|
);
|
|
61
61
|
}
|
|
62
62
|
if (config.ratelimit) {
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
65
|
-
throw new Error(
|
|
66
|
-
`Route ${method} ${urlPattern} (${filePath}) ratelimit.key must be a non-empty string`,
|
|
67
|
-
);
|
|
63
|
+
const policies = Array.isArray(config.ratelimit) ? config.ratelimit : [config.ratelimit];
|
|
64
|
+
if (policies.length === 0) {
|
|
65
|
+
throw new Error(`Route ${method} ${urlPattern} (${filePath}) ratelimit cannot be empty`);
|
|
68
66
|
}
|
|
69
|
-
config.
|
|
67
|
+
config._parsedRateLimits = policies.map((policy, index) => {
|
|
68
|
+
if (!policy?.key || typeof policy.key !== 'string') {
|
|
69
|
+
const suffix = policies.length > 1 ? `[${index}]` : '';
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Route ${method} ${urlPattern} (${filePath}) ratelimit${suffix}.key must be a non-empty string`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return { key: policy.key, ...parseRateLimit(policy.limit) };
|
|
75
|
+
});
|
|
76
|
+
// Preserve the existing internal field for integrations that inspect a
|
|
77
|
+
// route with one rate-limit policy.
|
|
78
|
+
config._parsedRateLimit = config._parsedRateLimits[0];
|
|
70
79
|
}
|
|
71
80
|
if (config.tags !== undefined) {
|
|
72
81
|
if (!Array.isArray(config.tags) || !config.tags.every((t) => typeof t === 'string')) {
|
|
@@ -78,9 +87,7 @@ function buildRoutesFromModules(entries, { prefix = '', plugin } = {}) {
|
|
|
78
87
|
}
|
|
79
88
|
if (config.name !== undefined) {
|
|
80
89
|
if (typeof config.name !== 'string') {
|
|
81
|
-
throw new Error(
|
|
82
|
-
`Route ${method} ${urlPattern} (${filePath}) name must be a string`,
|
|
83
|
-
);
|
|
90
|
+
throw new Error(`Route ${method} ${urlPattern} (${filePath}) name must be a string`);
|
|
84
91
|
}
|
|
85
92
|
config._name = config.name;
|
|
86
93
|
}
|
|
@@ -94,9 +101,7 @@ function buildRoutesFromModules(entries, { prefix = '', plugin } = {}) {
|
|
|
94
101
|
}
|
|
95
102
|
if (config.mcp !== undefined) {
|
|
96
103
|
if (typeof config.mcp !== 'object' || config.mcp === null) {
|
|
97
|
-
throw new Error(
|
|
98
|
-
`Route ${method} ${urlPattern} (${filePath}) mcp must be an object`,
|
|
99
|
-
);
|
|
104
|
+
throw new Error(`Route ${method} ${urlPattern} (${filePath}) mcp must be an object`);
|
|
100
105
|
}
|
|
101
106
|
config._mcp = config.mcp;
|
|
102
107
|
}
|