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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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.command('start').description('Start production server').action(() => startServer('production'));
33
+ program
34
+ .command('start')
35
+ .description('Start production server')
36
+ .action(() => startServer('production'));
28
37
  }
29
38
 
30
39
  export { startServer };
@@ -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
- export { generateNanoId, generateUUID };
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,
@@ -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 { checkRateLimit } from './ratelimit.js';
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({ method: fwdMethod, path: fwdPath, params: fwdParams, body: fwdBody, query: fwdQuery }, callerCtx) {
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 { status: 404, error: { code: ErrorCodes.NOT_FOUND, message: `No route for ${fwdMethod} ${fwdPath}` } };
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 = { ...(callerCtx.req?.query ?? {}), ...(fwdQuery ?? {}), ...matchedParams, ...(fwdParams ?? {}) };
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 { max, windowSec } = route.config._parsedRateLimit;
147
- const rl = await checkRateLimit(this._redis.client, {
148
- key: route.config.ratelimit.key,
149
- ip: reqInfo.ip,
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(rl.resetAt),
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 { max, windowSec } = route.config._parsedRateLimit;
272
- const rl = await checkRateLimit(this._redis.client, {
273
- key: route.config.ratelimit.key,
274
- ip,
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(rl.resetAt),
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(rl.remaining));
300
- res.setHeader('X-RateLimit-Reset', String(rl.resetAt));
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(`Invalid rate limit: expected "N per TIMERANGE", got ${JSON.stringify(limitStr)}`);
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(`Invalid rate limit: expected "N per TIMERANGE" (sec|min|hr|day), got "${limitStr}"`);
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
- export { parseRateLimit, checkRateLimit };
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 };
@@ -60,13 +60,22 @@ function buildRoutesFromModules(entries, { prefix = '', plugin } = {}) {
60
60
  );
61
61
  }
62
62
  if (config.ratelimit) {
63
- const rl = config.ratelimit;
64
- if (!rl.key || typeof rl.key !== 'string') {
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._parsedRateLimit = parseRateLimit(rl.limit);
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
  }