arcway 0.3.2 → 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/boot/index.js +1 -0
- package/server/context.js +1 -0
- 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 +97 -26
- package/server/router/ratelimit.js +40 -4
- package/server/router/routes.js +41 -4
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/boot/index.js
CHANGED
|
@@ -124,6 +124,7 @@ async function boot(options) {
|
|
|
124
124
|
sessionConfig: config.session,
|
|
125
125
|
});
|
|
126
126
|
await apiRouter.init();
|
|
127
|
+
appContext.api = apiRouter;
|
|
127
128
|
if (fileWatcher) plugins.watch(fileWatcher, { jobRunner });
|
|
128
129
|
|
|
129
130
|
const pagesRouter = new PagesRouter(config, { rootDir, log, mode, fileWatcher, appContext });
|
package/server/context.js
CHANGED
|
@@ -156,6 +156,7 @@ function buildContext(appContext, extras) {
|
|
|
156
156
|
};
|
|
157
157
|
ctx.callbacks = appContext.callbacks?.bind?.(ctx);
|
|
158
158
|
ctx.plugins = appContext.plugins?.buildContextProxy?.(ctx);
|
|
159
|
+
ctx.api = appContext.api?.buildContextApi?.(ctx) ?? null;
|
|
159
160
|
return ctx;
|
|
160
161
|
}
|
|
161
162
|
|
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,17 +71,93 @@ class ApiRouter {
|
|
|
71
71
|
return matchRoute(this._routes, method, path);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
async forward(
|
|
75
|
+
{ method: fwdMethod, path: fwdPath, params: fwdParams, body: fwdBody, query: fwdQuery },
|
|
76
|
+
callerCtx,
|
|
77
|
+
) {
|
|
78
|
+
const matched = matchRoute(this._routes, fwdMethod, fwdPath);
|
|
79
|
+
if (!matched) {
|
|
80
|
+
return {
|
|
81
|
+
status: 404,
|
|
82
|
+
error: { code: ErrorCodes.NOT_FOUND, message: `No route for ${fwdMethod} ${fwdPath}` },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const { route, params: matchedParams } = matched;
|
|
86
|
+
|
|
87
|
+
const mergedQuery = {
|
|
88
|
+
...(callerCtx.req?.query ?? {}),
|
|
89
|
+
...(fwdQuery ?? {}),
|
|
90
|
+
...matchedParams,
|
|
91
|
+
...(fwdParams ?? {}),
|
|
92
|
+
};
|
|
93
|
+
const reqBody = fwdBody !== undefined ? fwdBody : callerCtx.req?.body;
|
|
94
|
+
const validated = validateRequestSchema(route.config.schema, mergedQuery, reqBody);
|
|
95
|
+
if (validated.error) {
|
|
96
|
+
return { status: 400, error: validated.error };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const reqInfo = {
|
|
100
|
+
id: callerCtx.req?.id,
|
|
101
|
+
ip: callerCtx.req?.ip ?? '127.0.0.1',
|
|
102
|
+
method: fwdMethod,
|
|
103
|
+
path: fwdPath,
|
|
104
|
+
query: validated.query,
|
|
105
|
+
body: validated.body,
|
|
106
|
+
rawBody: callerCtx.req?.rawBody,
|
|
107
|
+
headers: callerCtx.req?.headers ?? {},
|
|
108
|
+
cookies: callerCtx.req?.cookies ?? {},
|
|
109
|
+
session: callerCtx.req?.session,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const middlewareFns = getMiddlewareForRoute(this._middleware, route.pattern, fwdMethod);
|
|
113
|
+
const chainedHandler = buildMiddlewareChain(middlewareFns, route.config.handler);
|
|
114
|
+
const ctx = this._buildCtx(reqInfo);
|
|
115
|
+
try {
|
|
116
|
+
const response = await chainedHandler(ctx);
|
|
117
|
+
response.status = response.status ?? (response.error ? 400 : 200);
|
|
118
|
+
return response;
|
|
119
|
+
} catch (err) {
|
|
120
|
+
this._log.error(`Forward handler error in ${fwdMethod} ${route.pattern}`, {
|
|
121
|
+
error: toErrorMessage(err),
|
|
122
|
+
});
|
|
123
|
+
const errorMessage =
|
|
124
|
+
this._mode === 'development'
|
|
125
|
+
? err instanceof Error
|
|
126
|
+
? err.stack || err.message
|
|
127
|
+
: String(err)
|
|
128
|
+
: 'An internal error occurred';
|
|
129
|
+
return {
|
|
130
|
+
status: 500,
|
|
131
|
+
error: { code: ErrorCodes.HANDLER_ERROR, message: errorMessage },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
routesApi() {
|
|
137
|
+
return this._routes.map((route) => ({
|
|
138
|
+
method: route.method,
|
|
139
|
+
pattern: route.pattern,
|
|
140
|
+
tags: route.config._tags,
|
|
141
|
+
name: route.config._name,
|
|
142
|
+
description: route.config._description,
|
|
143
|
+
mcp: route.config._mcp,
|
|
144
|
+
schema: route.config.schema,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
buildContextApi(ctx) {
|
|
149
|
+
return {
|
|
150
|
+
forward: (args) => this.forward(args, ctx),
|
|
151
|
+
routes: () => this.routesApi(),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
74
155
|
async executeRoute(route, reqInfo) {
|
|
75
156
|
if (route.config._parsedRateLimit && this._redis) {
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
max,
|
|
81
|
-
windowSec,
|
|
82
|
-
});
|
|
83
|
-
if (!rl.allowed) {
|
|
84
|
-
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));
|
|
85
161
|
return {
|
|
86
162
|
status: 429,
|
|
87
163
|
error: {
|
|
@@ -89,9 +165,9 @@ class ApiRouter {
|
|
|
89
165
|
message: `Rate limit exceeded. Try again in ${retryAfter} seconds.`,
|
|
90
166
|
},
|
|
91
167
|
headers: {
|
|
92
|
-
'X-RateLimit-Limit': String(max),
|
|
168
|
+
'X-RateLimit-Limit': String(policy.max),
|
|
93
169
|
'X-RateLimit-Remaining': '0',
|
|
94
|
-
'X-RateLimit-Reset': String(
|
|
170
|
+
'X-RateLimit-Reset': String(result.resetAt),
|
|
95
171
|
'Retry-After': String(retryAfter),
|
|
96
172
|
},
|
|
97
173
|
};
|
|
@@ -198,15 +274,10 @@ class ApiRouter {
|
|
|
198
274
|
|
|
199
275
|
// ── Rate limiting ──
|
|
200
276
|
if (route.config._parsedRateLimit && this._redis) {
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
max,
|
|
206
|
-
windowSec,
|
|
207
|
-
});
|
|
208
|
-
if (!rl.allowed) {
|
|
209
|
-
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));
|
|
210
281
|
sendJson(
|
|
211
282
|
res,
|
|
212
283
|
429,
|
|
@@ -217,17 +288,17 @@ class ApiRouter {
|
|
|
217
288
|
},
|
|
218
289
|
},
|
|
219
290
|
{
|
|
220
|
-
'X-RateLimit-Limit': String(max),
|
|
291
|
+
'X-RateLimit-Limit': String(policy.max),
|
|
221
292
|
'X-RateLimit-Remaining': '0',
|
|
222
|
-
'X-RateLimit-Reset': String(
|
|
293
|
+
'X-RateLimit-Reset': String(result.resetAt),
|
|
223
294
|
'Retry-After': String(retryAfter),
|
|
224
295
|
},
|
|
225
296
|
);
|
|
226
297
|
return true;
|
|
227
298
|
}
|
|
228
|
-
res.setHeader('X-RateLimit-Limit', String(max));
|
|
229
|
-
res.setHeader('X-RateLimit-Remaining', String(
|
|
230
|
-
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));
|
|
231
302
|
}
|
|
232
303
|
|
|
233
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,50 @@ function buildRoutesFromModules(entries, { prefix = '', plugin } = {}) {
|
|
|
60
60
|
);
|
|
61
61
|
}
|
|
62
62
|
if (config.ratelimit) {
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
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`);
|
|
66
|
+
}
|
|
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];
|
|
79
|
+
}
|
|
80
|
+
if (config.tags !== undefined) {
|
|
81
|
+
if (!Array.isArray(config.tags) || !config.tags.every((t) => typeof t === 'string')) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Route ${method} ${urlPattern} (${filePath}) tags must be an array of strings`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
config._tags = config.tags;
|
|
87
|
+
}
|
|
88
|
+
if (config.name !== undefined) {
|
|
89
|
+
if (typeof config.name !== 'string') {
|
|
90
|
+
throw new Error(`Route ${method} ${urlPattern} (${filePath}) name must be a string`);
|
|
91
|
+
}
|
|
92
|
+
config._name = config.name;
|
|
93
|
+
}
|
|
94
|
+
if (config.description !== undefined) {
|
|
95
|
+
if (typeof config.description !== 'string') {
|
|
65
96
|
throw new Error(
|
|
66
|
-
`Route ${method} ${urlPattern} (${filePath})
|
|
97
|
+
`Route ${method} ${urlPattern} (${filePath}) description must be a string`,
|
|
67
98
|
);
|
|
68
99
|
}
|
|
69
|
-
config.
|
|
100
|
+
config._description = config.description;
|
|
101
|
+
}
|
|
102
|
+
if (config.mcp !== undefined) {
|
|
103
|
+
if (typeof config.mcp !== 'object' || config.mcp === null) {
|
|
104
|
+
throw new Error(`Route ${method} ${urlPattern} (${filePath}) mcp must be an object`);
|
|
105
|
+
}
|
|
106
|
+
config._mcp = config.mcp;
|
|
70
107
|
}
|
|
71
108
|
routes.push({
|
|
72
109
|
method,
|