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.
- package/package.json +1 -1
- package/server/bin/cli.js +2 -0
- package/server/bin/commands/bootstrap.js +21 -14
- package/server/bin/commands/vault.js +14 -0
- package/server/boot/index.js +20 -2
- package/server/config/loader.js +2 -2
- package/server/config/modules/mcp.js +2 -2
- package/server/config/modules/session.js +7 -1
- package/server/config/modules/vault.js +44 -0
- package/server/context.js +30 -5
- package/server/jobs/runner.js +29 -10
- package/server/jobs/worker-entry.js +9 -2
- package/server/lib/vault/index.js +10 -2
- package/server/lib/vault/jwt.js +34 -21
- package/server/lib/vault/secrets.js +64 -17
- package/server/plugins/bundle.js +122 -0
- package/server/plugins/discovery.js +45 -6
- package/server/plugins/graph.js +22 -8
- package/server/plugins/manager.js +196 -11
- package/server/plugins/manifest.js +27 -31
- package/server/router/api-router.js +4 -0
- package/server/router/routes.js +7 -2
- package/server/session/index.js +57 -15
- package/server/testing/index.js +1 -0
- package/server/config/modules/secrets.js +0 -145
|
@@ -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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
package/server/router/routes.js
CHANGED
|
@@ -46,8 +46,7 @@ function prefixRoutePattern(pattern, prefix) {
|
|
|
46
46
|
if (pattern === '/') return normalized;
|
|
47
47
|
return normalized + pattern;
|
|
48
48
|
}
|
|
49
|
-
|
|
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,
|
package/server/session/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/server/testing/index.js
CHANGED
|
@@ -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;
|