arcway 0.1.30 → 0.2.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.
@@ -1,6 +1,4 @@
1
1
  const ID_PATTERN = /^[a-z][a-z0-9-]*$/;
2
- const KINDS = new Set(['core', 'feature']);
3
- const SCOPES = new Set(['global', 'workspace']);
4
2
 
5
3
  function assertPlainObject(value, label) {
6
4
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -22,18 +20,6 @@ function normalizeRequires(value) {
22
20
  return { ...value };
23
21
  }
24
22
 
25
- function normalizeContributes(value) {
26
- if (value === undefined) return {};
27
- assertPlainObject(value, 'plugin contributes');
28
- return {
29
- ...value,
30
- tools: value.tools ?? [],
31
- routes: value.routes ?? [],
32
- jobs: value.jobs ?? [],
33
- ui: value.ui,
34
- };
35
- }
36
-
37
23
  function normalizeLifecycle(value) {
38
24
  if (value === undefined) return {};
39
25
  assertPlainObject(value, 'plugin lifecycle');
@@ -52,17 +38,34 @@ function normalizeLifecycle(value) {
52
38
  function validateManifest(rawManifest, { packageName } = {}) {
53
39
  assertPlainObject(rawManifest, 'plugin manifest');
54
40
 
41
+ if (rawManifest.enabled !== undefined && typeof rawManifest.enabled !== 'boolean') {
42
+ throw new Error('plugin enabled must be a boolean');
43
+ }
44
+
45
+ const { id, name, version, enabled, provides, requires, lifecycle } = rawManifest;
46
+ const appMetadata = { ...rawManifest };
47
+ for (const key of [
48
+ 'id',
49
+ 'name',
50
+ 'version',
51
+ 'defaultEnabled',
52
+ 'enabled',
53
+ 'provides',
54
+ 'requires',
55
+ 'lifecycle',
56
+ ]) {
57
+ delete appMetadata[key];
58
+ }
59
+
55
60
  const manifest = {
56
- id: rawManifest.id ?? packageName,
57
- name: rawManifest.name ?? rawManifest.id ?? packageName,
58
- version: rawManifest.version,
59
- kind: rawManifest.kind ?? 'feature',
60
- scope: rawManifest.scope ?? 'workspace',
61
- defaultEnabled: rawManifest.defaultEnabled === true,
62
- provides: normalizeStringArray(rawManifest.provides, 'plugin provides'),
63
- requires: normalizeRequires(rawManifest.requires),
64
- contributes: normalizeContributes(rawManifest.contributes),
65
- lifecycle: normalizeLifecycle(rawManifest.lifecycle),
61
+ ...appMetadata,
62
+ id: id ?? packageName,
63
+ name: name ?? id ?? packageName,
64
+ version,
65
+ enabled: enabled !== false,
66
+ provides: normalizeStringArray(provides, 'plugin provides'),
67
+ requires: normalizeRequires(requires),
68
+ lifecycle: normalizeLifecycle(lifecycle),
66
69
  };
67
70
 
68
71
  if (typeof manifest.id !== 'string' || !ID_PATTERN.test(manifest.id)) {
@@ -74,13 +77,6 @@ function validateManifest(rawManifest, { packageName } = {}) {
74
77
  if (typeof manifest.version !== 'string' || manifest.version === '') {
75
78
  throw new Error(`plugin ${manifest.id} version must be a non-empty string`);
76
79
  }
77
- if (!KINDS.has(manifest.kind)) {
78
- throw new Error(`plugin ${manifest.id} kind must be core or feature`);
79
- }
80
- if (!SCOPES.has(manifest.scope)) {
81
- throw new Error(`plugin ${manifest.id} scope must be global or workspace`);
82
- }
83
-
84
80
  return manifest;
85
81
  }
86
82
 
@@ -347,6 +347,10 @@ class ApiRouter {
347
347
  }
348
348
  }
349
349
 
350
+ async reload() {
351
+ return this._reload();
352
+ }
353
+
350
354
  async close() {
351
355
  if (this._mode === 'development' && this._fileWatcher) {
352
356
  this._fileWatcher.unsubscribe('routes');
@@ -46,8 +46,7 @@ function prefixRoutePattern(pattern, prefix) {
46
46
  if (pattern === '/') return normalized;
47
47
  return normalized + pattern;
48
48
  }
49
- async function discoverRoutes(apiDir, { prefix = '', plugin } = {}) {
50
- const entries = await discoverModules(apiDir, { recursive: true, label: 'route file' });
49
+ function buildRoutesFromModules(entries, { prefix = '', plugin } = {}) {
51
50
  const routes = [];
52
51
  for (const { filePath, relativePath, module } of entries) {
53
52
  const urlPattern = prefixRoutePattern(filePathToPattern(relativePath), prefix);
@@ -84,6 +83,11 @@ async function discoverRoutes(apiDir, { prefix = '', plugin } = {}) {
84
83
  sortBySpecificity(routes);
85
84
  return routes;
86
85
  }
86
+
87
+ async function discoverRoutes(apiDir, { prefix = '', plugin } = {}) {
88
+ const entries = await discoverModules(apiDir, { recursive: true, label: 'route file' });
89
+ return buildRoutesFromModules(entries, { prefix, plugin });
90
+ }
87
91
  function matchRoute(routes, method, pathname) {
88
92
  const upperMethod = method.toUpperCase();
89
93
  const methodRoutes = routes.filter((r) => r.method === upperMethod);
@@ -106,6 +110,7 @@ function matchRoutesByPath(routes, pathname) {
106
110
  }
107
111
  export {
108
112
  compilePattern,
113
+ buildRoutesFromModules,
109
114
  discoverRoutes,
110
115
  filePathToPattern,
111
116
  matchPattern,
@@ -4,29 +4,71 @@ import { SESSION_COOKIE_MAX_AGE_SKEW } from '../constants.js';
4
4
  import { toErrorMessage } from '../helpers.js';
5
5
  import { normalizeDurationSeconds } from '../config/duration.js';
6
6
  const MIN_PASSWORD_LENGTH = 32;
7
- function resolveSessionConfig(config, mode) {
7
+
8
+ function assertPasswordLength(password, label) {
9
+ if (typeof password !== 'string') return;
10
+ if (password.length < MIN_PASSWORD_LENGTH) {
11
+ throw new Error(
12
+ `${label} must be at least ${MIN_PASSWORD_LENGTH} characters (got ${password.length}). Short passwords produce weak encryption.`,
13
+ );
14
+ }
15
+ }
16
+
17
+ function normalizePasswordConfig(config) {
18
+ if (Array.isArray(config.keyring) && config.keyring.length > 0) {
19
+ const password = {};
20
+ for (const entry of config.keyring) {
21
+ const id = String(entry.id ?? '');
22
+ if (!id) throw new Error('Session keyring entries require stable ids');
23
+ if (!/^\d+$/.test(id)) throw new Error(`Session keyring id=${id} must be numeric`);
24
+ assertPasswordLength(entry.password, `Session password id=${id}`);
25
+ password[id] = entry.password;
26
+ }
27
+
28
+ const active = config.keyring.at(-1);
29
+ const activeId = String(active.id);
30
+ return {
31
+ password,
32
+ sealPassword: { [activeId]: active.password },
33
+ activePasswordId: activeId,
34
+ };
35
+ }
36
+
8
37
  if (!config.password) {
9
38
  throw new Error(
10
- `Session "password" is required but was not provided (got ${config.password === void 0 ? 'undefined' : String(config.password)}). Set a password of at least 32 characters, e.g. via SESSION_SECRET env variable.`,
39
+ `Session "password" is required but was not provided (got ${config.password === void 0 ? 'undefined' : String(config.password)}). Set vault.masterSecret to provide a session key.`,
11
40
  );
12
41
  }
42
+
13
43
  if (typeof config.password === 'string') {
14
- if (config.password.length < MIN_PASSWORD_LENGTH) {
15
- throw new Error(
16
- `Session password must be at least ${MIN_PASSWORD_LENGTH} characters (got ${config.password.length}). Short passwords produce weak encryption.`,
17
- );
18
- }
19
- } else if (typeof config.password === 'object') {
44
+ assertPasswordLength(config.password, 'Session password');
45
+ return {
46
+ password: config.password,
47
+ sealPassword: config.password,
48
+ activePasswordId: null,
49
+ };
50
+ }
51
+
52
+ if (typeof config.password === 'object') {
20
53
  for (const [id, pw] of Object.entries(config.password)) {
21
- if (typeof pw === 'string' && pw.length < MIN_PASSWORD_LENGTH) {
22
- throw new Error(
23
- `Session password id=${id} must be at least ${MIN_PASSWORD_LENGTH} characters (got ${pw.length}). Short passwords produce weak encryption.`,
24
- );
25
- }
54
+ assertPasswordLength(pw, `Session password id=${id}`);
26
55
  }
56
+ return {
57
+ password: config.password,
58
+ sealPassword: config.password,
59
+ activePasswordId: String(Math.max(...Object.keys(config.password).map(Number))),
60
+ };
27
61
  }
62
+
63
+ throw new Error('Session password must be a string or password map');
64
+ }
65
+
66
+ function resolveSessionConfig(config, mode) {
67
+ const { password, sealPassword, activePasswordId } = normalizePasswordConfig(config);
28
68
  return {
29
- password: config.password,
69
+ password,
70
+ sealPassword,
71
+ activePasswordId,
30
72
  cookieName: config.cookieName ?? 'arcway.session',
31
73
  ttl:
32
74
  config.ttl === undefined
@@ -60,7 +102,7 @@ async function unsealSession(cookieValue, config) {
60
102
  }
61
103
  async function sealSession(data, config) {
62
104
  return sealData(data, {
63
- password: config.password,
105
+ password: config.sealPassword ?? config.password,
64
106
  ttl: config.ttl,
65
107
  });
66
108
  }
@@ -188,6 +188,7 @@ async function testBoot(options) {
188
188
  },
189
189
  mail: { send: async () => ({ accepted: [], rejected: [] }), queue: async () => {} },
190
190
  log: { debug() {}, info() {}, warn() {}, error() {} },
191
+ vault: app.config?.vault,
191
192
  };
192
193
  async function request(method, urlPath, opts) {
193
194
  const url = new URL(urlPath, baseUrl);
@@ -1,145 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import {
4
- deriveInfraSecrets,
5
- encodeMasterSecret,
6
- normalizeMasterSecret,
7
- validateVaultKey,
8
- } from '../../lib/vault/secrets.js';
9
-
10
- const DEFAULT_KEY_FILE = '.build/arcway-master-secret.key';
11
-
12
- const EXPLICIT_ENV = Object.freeze({
13
- session: 'SESSION_SECRET',
14
- 'plugin-vault': 'PLUGIN_VAULT_KEY',
15
- 'mcp-api': 'MCP_API_KEY',
16
- 'pty-api': 'PTY_API_KEY',
17
- 'pty-server-api': 'PTY_SERVER_API_KEY',
18
- });
19
-
20
- function isProduction(mode) {
21
- return mode === 'production';
22
- }
23
-
24
- function readKeyFile(filePath) {
25
- try {
26
- const stat = fs.statSync(filePath);
27
- if ((stat.mode & 0o077) !== 0) {
28
- throw new Error('Arcway master secret keyfile permissions must be 0600');
29
- }
30
- return fs.readFileSync(filePath, 'utf8').trim();
31
- } catch (error) {
32
- if (error.code === 'ENOENT') return null;
33
- throw error;
34
- }
35
- }
36
-
37
- function createDevKeyFile(filePath) {
38
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
39
- const encoded = encodeMasterSecret();
40
-
41
- try {
42
- const fd = fs.openSync(
43
- filePath,
44
- fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY,
45
- 0o600,
46
- );
47
- try {
48
- fs.writeFileSync(fd, `${encoded}\n`, 'utf8');
49
- } finally {
50
- fs.closeSync(fd);
51
- }
52
- return encoded;
53
- } catch (error) {
54
- if (error.code === 'EEXIST') return readKeyFile(filePath);
55
- throw error;
56
- }
57
- }
58
-
59
- function loadMasterSecret(rawSecrets, { rootDir, mode, env }) {
60
- const configured = rawSecrets.master ?? env.ARCWAY_MASTER_SECRET;
61
- if (configured) return { value: configured, source: 'config' };
62
-
63
- if (isProduction(mode)) {
64
- throw new Error(
65
- 'ARCWAY_MASTER_SECRET is required when arcway.config secrets are enabled in production',
66
- );
67
- }
68
-
69
- const keyFile = path.resolve(rootDir, rawSecrets.keyFile ?? DEFAULT_KEY_FILE);
70
- const existing = readKeyFile(keyFile);
71
- if (existing) return { value: existing, source: 'keyfile', keyFile };
72
-
73
- return { value: createDevKeyFile(keyFile), source: 'generated-keyfile', keyFile };
74
- }
75
-
76
- function explicitValue(name, rawSecrets, env) {
77
- const explicit = rawSecrets.explicit?.[name];
78
- if (explicit !== undefined && explicit !== null && explicit !== '') return explicit;
79
-
80
- const envName = EXPLICIT_ENV[name];
81
- const fromEnv = envName ? env[envName] : undefined;
82
- return fromEnv || undefined;
83
- }
84
-
85
- function validateExplicit(name, value) {
86
- if (name === 'plugin-vault') return validateVaultKey(value);
87
- return String(value);
88
- }
89
-
90
- function resolve(config, { rootDir, mode } = {}) {
91
- if (!config.secrets) return config;
92
-
93
- const env = config.secrets.env ?? process.env;
94
- const rawSecrets = config.secrets;
95
- const master = loadMasterSecret(rawSecrets, { rootDir, mode, env });
96
- normalizeMasterSecret(master.value);
97
-
98
- const derived = deriveInfraSecrets(master.value);
99
- const values = { ...derived };
100
- const explicit = {};
101
- const previousValues = {};
102
-
103
- const previousMaster = rawSecrets.previous ?? env.ARCWAY_MASTER_SECRET_PREVIOUS;
104
- if (previousMaster) {
105
- normalizeMasterSecret(previousMaster, 'Previous Arcway master secret');
106
- Object.assign(previousValues, deriveInfraSecrets(previousMaster));
107
- }
108
-
109
- for (const name of Object.keys(EXPLICIT_ENV)) {
110
- const value = explicitValue(name, rawSecrets, env);
111
- if (!value) continue;
112
-
113
- explicit[name] = validateExplicit(name, value);
114
- values[name] = explicit[name];
115
- if (derived[name] !== explicit[name]) {
116
- previousValues[name] = explicit[name];
117
- }
118
- }
119
-
120
- const vapidPublic = rawSecrets.explicit?.vapidPublic ?? env.VAPID_PUBLIC_KEY;
121
- const vapidPrivate = rawSecrets.explicit?.vapidPrivate ?? env.VAPID_PRIVATE_KEY;
122
- if (vapidPublic || vapidPrivate) {
123
- if (!vapidPublic || !vapidPrivate) {
124
- throw new Error('VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be provided together');
125
- }
126
- explicit.vapid = { publicKey: String(vapidPublic), privateKey: String(vapidPrivate) };
127
- values.vapid = explicit.vapid;
128
- previousValues.vapid = explicit.vapid;
129
- }
130
-
131
- return {
132
- ...config,
133
- secrets: {
134
- enabled: true,
135
- keyFile: master.keyFile,
136
- source: master.source,
137
- values,
138
- derived,
139
- explicit,
140
- previousValues,
141
- },
142
- };
143
- }
144
-
145
- export default resolve;