arcway 0.1.29 → 0.2.0
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 +8 -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 +14 -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 +27 -5
- package/server/db/index.js +5 -17
- package/server/jobs/runner.js +2 -2
- package/server/jobs/worker-entry.js +9 -2
- package/server/lib/vault/index.js +10 -2
- package/server/lib/vault/secrets.js +63 -17
- package/server/plugins/discovery.js +51 -8
- package/server/plugins/manager.js +48 -13
- package/server/plugins/manifest.js +27 -32
- package/server/session/index.js +57 -15
- package/server/testing/index.js +1 -0
- package/server/config/modules/secrets.js +0 -145
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arcway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -137,5 +137,12 @@
|
|
|
137
137
|
"react-dom": "^19.2.4",
|
|
138
138
|
"storybook": "^10.2.8",
|
|
139
139
|
"vitest": "^4.0.18"
|
|
140
|
+
},
|
|
141
|
+
"pnpm": {
|
|
142
|
+
"onlyBuiltDependencies": [
|
|
143
|
+
"better-sqlite3",
|
|
144
|
+
"esbuild",
|
|
145
|
+
"sharp"
|
|
146
|
+
]
|
|
140
147
|
}
|
|
141
148
|
}
|
package/server/bin/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import registerGraphQLSchema from './commands/graphql-schema.js';
|
|
|
12
12
|
import registerSchema from './commands/schema.js';
|
|
13
13
|
import registerMigrate from './commands/migrate.js';
|
|
14
14
|
import registerBootstrap from './commands/bootstrap.js';
|
|
15
|
+
import registerVault from './commands/vault.js';
|
|
15
16
|
|
|
16
17
|
function getPackageVersion() {
|
|
17
18
|
try {
|
|
@@ -38,6 +39,7 @@ function createProgram() {
|
|
|
38
39
|
registerSchema(program);
|
|
39
40
|
registerMigrate(program);
|
|
40
41
|
registerBootstrap(program);
|
|
42
|
+
registerVault(program);
|
|
41
43
|
return program;
|
|
42
44
|
}
|
|
43
45
|
|
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { encodeMasterSecret } from '../../lib/vault/index.js';
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
5
|
+
function createFiles(masterSecret) {
|
|
6
|
+
return {
|
|
7
|
+
'arcway.config.js': `export default {
|
|
6
8
|
database: {
|
|
7
9
|
client: 'better-sqlite3',
|
|
8
10
|
connection: { filename: './data.db' },
|
|
9
11
|
},
|
|
12
|
+
vault: {
|
|
13
|
+
masterSecret: process.env.ARCWAY_MASTER_SECRET,
|
|
14
|
+
},
|
|
10
15
|
};
|
|
11
16
|
`,
|
|
12
17
|
|
|
13
|
-
|
|
18
|
+
'api/index.js': `export const GET = {
|
|
14
19
|
handler: async (ctx) => {
|
|
15
20
|
return { data: { message: 'Hello from Arcway!' } };
|
|
16
21
|
},
|
|
17
22
|
};
|
|
18
23
|
`,
|
|
19
24
|
|
|
20
|
-
|
|
25
|
+
'api/users/index.js': `export const GET = {
|
|
21
26
|
handler: async (ctx) => {
|
|
22
27
|
const { db } = ctx;
|
|
23
28
|
const users = await db('users').select('*');
|
|
@@ -38,7 +43,7 @@ export const POST = {
|
|
|
38
43
|
};
|
|
39
44
|
`,
|
|
40
45
|
|
|
41
|
-
|
|
46
|
+
'api/users/[id].js': `export const GET = {
|
|
42
47
|
handler: async (ctx) => {
|
|
43
48
|
const { req, db } = ctx;
|
|
44
49
|
const user = await db('users').where('id', req.query.id).first();
|
|
@@ -48,13 +53,13 @@ export const POST = {
|
|
|
48
53
|
};
|
|
49
54
|
`,
|
|
50
55
|
|
|
51
|
-
|
|
56
|
+
'listeners/users/created.js': `export default async (ctx) => {
|
|
52
57
|
const { event, log } = ctx;
|
|
53
58
|
log.info('New user created', { userId: event.payload.id });
|
|
54
59
|
};
|
|
55
60
|
`,
|
|
56
61
|
|
|
57
|
-
|
|
62
|
+
'jobs/cleanup-sessions.js': `export default {
|
|
58
63
|
schedule: '0 3 * * *', // Every day at 3 AM
|
|
59
64
|
handler: async (ctx) => {
|
|
60
65
|
const { db, log } = ctx;
|
|
@@ -66,7 +71,7 @@ export const POST = {
|
|
|
66
71
|
};
|
|
67
72
|
`,
|
|
68
73
|
|
|
69
|
-
|
|
74
|
+
'migrations/202601010000-create-users.js': `export async function up(knex) {
|
|
70
75
|
await knex.schema.createTable('users', (table) => {
|
|
71
76
|
table.increments('id').primary();
|
|
72
77
|
table.string('name').notNullable();
|
|
@@ -80,7 +85,7 @@ export async function down(knex) {
|
|
|
80
85
|
}
|
|
81
86
|
`,
|
|
82
87
|
|
|
83
|
-
|
|
88
|
+
'pages/index.jsx': `export default function Home() {
|
|
84
89
|
return (
|
|
85
90
|
<div style={{ padding: '2rem', fontFamily: 'system-ui' }}>
|
|
86
91
|
<h1>Welcome to Arcway</h1>
|
|
@@ -90,23 +95,25 @@ export async function down(knex) {
|
|
|
90
95
|
}
|
|
91
96
|
`,
|
|
92
97
|
|
|
93
|
-
|
|
94
|
-
|
|
98
|
+
'.env': `# NODE_ENV=development
|
|
99
|
+
ARCWAY_MASTER_SECRET=${masterSecret}
|
|
95
100
|
`,
|
|
96
101
|
|
|
97
|
-
|
|
102
|
+
'.gitignore': `node_modules/
|
|
98
103
|
.build/
|
|
99
104
|
data.db
|
|
100
105
|
.env.local
|
|
101
106
|
.env.*.local
|
|
102
107
|
`,
|
|
103
|
-
};
|
|
108
|
+
};
|
|
109
|
+
}
|
|
104
110
|
|
|
105
111
|
function bootstrap(rootDir) {
|
|
112
|
+
const files = createFiles(encodeMasterSecret());
|
|
106
113
|
let created = 0;
|
|
107
114
|
let skipped = 0;
|
|
108
115
|
|
|
109
|
-
for (const [relativePath, content] of Object.entries(
|
|
116
|
+
for (const [relativePath, content] of Object.entries(files)) {
|
|
110
117
|
const fullPath = join(rootDir, relativePath);
|
|
111
118
|
if (existsSync(fullPath)) {
|
|
112
119
|
console.log(` skip ${relativePath} (already exists)`);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { encodeMasterSecret } from '../../lib/vault/index.js';
|
|
2
|
+
|
|
3
|
+
function registerVault(program) {
|
|
4
|
+
const vault = program.command('vault').description('Vault utilities');
|
|
5
|
+
|
|
6
|
+
vault
|
|
7
|
+
.command('generate-key')
|
|
8
|
+
.description('Generate a new Arcway master secret')
|
|
9
|
+
.action(() => {
|
|
10
|
+
console.log(encodeMasterSecret());
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default registerVault;
|
package/server/boot/index.js
CHANGED
|
@@ -29,7 +29,6 @@ async function boot(options) {
|
|
|
29
29
|
const config = await makeConfig(rootDir, { overrides: options.configOverrides, mode });
|
|
30
30
|
|
|
31
31
|
const plugins = await createPluginManager(config.plugins, { runLifecycle: false });
|
|
32
|
-
config.database.pluginMigrations = plugins.migrations();
|
|
33
32
|
|
|
34
33
|
const infrastructure = await createInfrastructure(config, { runMigrations: true });
|
|
35
34
|
const { db, redis, queue, cache, files, mail, events, log } = infrastructure;
|
|
@@ -44,7 +43,19 @@ async function boot(options) {
|
|
|
44
43
|
// framework services and to the init/ready/shutdown hooks. Mutations from
|
|
45
44
|
// the init hook (e.g. wrapping `appContext.db`) persist for process lifetime
|
|
46
45
|
// because downstream consumers read properties off this reference at use time.
|
|
47
|
-
const appContext = {
|
|
46
|
+
const appContext = {
|
|
47
|
+
db,
|
|
48
|
+
redis,
|
|
49
|
+
events,
|
|
50
|
+
queue,
|
|
51
|
+
cache,
|
|
52
|
+
files,
|
|
53
|
+
mail,
|
|
54
|
+
log,
|
|
55
|
+
fileWatcher,
|
|
56
|
+
meta,
|
|
57
|
+
vault: config.vault,
|
|
58
|
+
};
|
|
48
59
|
|
|
49
60
|
plugins.log = log;
|
|
50
61
|
appContext.plugins = plugins;
|
|
@@ -83,6 +94,7 @@ async function boot(options) {
|
|
|
83
94
|
events,
|
|
84
95
|
log,
|
|
85
96
|
meta,
|
|
97
|
+
vault: config.vault,
|
|
86
98
|
workerPool,
|
|
87
99
|
plugins,
|
|
88
100
|
});
|
package/server/config/loader.js
CHANGED
|
@@ -12,7 +12,7 @@ import resolveRedis from './modules/redis.js';
|
|
|
12
12
|
import resolveCache from './modules/cache.js';
|
|
13
13
|
import resolveEvents from './modules/events.js';
|
|
14
14
|
import resolveMail from './modules/mail.js';
|
|
15
|
-
import
|
|
15
|
+
import resolveVault from './modules/vault.js';
|
|
16
16
|
import resolveSession from './modules/session.js';
|
|
17
17
|
import resolvePages from './modules/pages.js';
|
|
18
18
|
import resolveBuild from './modules/build.js';
|
|
@@ -51,7 +51,7 @@ const modules = [
|
|
|
51
51
|
resolveFiles,
|
|
52
52
|
resolveMail,
|
|
53
53
|
resolveRedis,
|
|
54
|
-
|
|
54
|
+
resolveVault,
|
|
55
55
|
resolveSession,
|
|
56
56
|
resolvePages,
|
|
57
57
|
resolveBuild,
|
|
@@ -4,8 +4,8 @@ const DEFAULTS = {
|
|
|
4
4
|
|
|
5
5
|
function resolve(config) {
|
|
6
6
|
const mcp = { ...DEFAULTS, ...config.mcp };
|
|
7
|
-
if (!mcp.secret && config.
|
|
8
|
-
mcp.secret = config.
|
|
7
|
+
if (!mcp.secret && config.vault?.values?.['mcp-api']) {
|
|
8
|
+
mcp.secret = config.vault.values['mcp-api'];
|
|
9
9
|
}
|
|
10
10
|
return { ...config, mcp };
|
|
11
11
|
}
|
|
@@ -2,9 +2,15 @@ import { resolveSessionConfig } from '../../session/index.js';
|
|
|
2
2
|
|
|
3
3
|
function resolve(config, { mode } = {}) {
|
|
4
4
|
if (!config.session) return config;
|
|
5
|
+
const vaultSession = config.vault?.values?.session;
|
|
6
|
+
const sessionKeyring = config.vault?.keyring?.map((entry) => ({
|
|
7
|
+
id: entry.id,
|
|
8
|
+
password: entry.secrets.session,
|
|
9
|
+
}));
|
|
5
10
|
const sessionInput = {
|
|
6
11
|
...config.session,
|
|
7
|
-
password:
|
|
12
|
+
password: vaultSession,
|
|
13
|
+
keyring: sessionKeyring,
|
|
8
14
|
};
|
|
9
15
|
const session = resolveSessionConfig(sessionInput, mode);
|
|
10
16
|
return { ...config, session };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deriveInfraSecretRing,
|
|
3
|
+
deriveInfraSecrets,
|
|
4
|
+
normalizeMasterSecretRing,
|
|
5
|
+
} from '../../lib/vault/secrets.js';
|
|
6
|
+
|
|
7
|
+
function loadMasterSecret(rawVault) {
|
|
8
|
+
if (rawVault.masterSecret !== undefined && rawVault.masterSecret !== null) {
|
|
9
|
+
return { value: rawVault.masterSecret, source: 'config' };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
throw new Error(
|
|
13
|
+
'vault.masterSecret is required when Arcway vault is enabled. Generate one with `npx arcway vault generate-key`.',
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function resolve(config) {
|
|
18
|
+
if (!config.vault) return config;
|
|
19
|
+
|
|
20
|
+
const rawVault = config.vault;
|
|
21
|
+
const master = loadMasterSecret(rawVault);
|
|
22
|
+
normalizeMasterSecretRing(master.value);
|
|
23
|
+
|
|
24
|
+
const ring = deriveInfraSecretRing(master.value);
|
|
25
|
+
const derived = deriveInfraSecrets(
|
|
26
|
+
Array.isArray(master.value) ? master.value.at(-1) : master.value,
|
|
27
|
+
);
|
|
28
|
+
const values = ring.active.secrets;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
...config,
|
|
32
|
+
vault: {
|
|
33
|
+
enabled: true,
|
|
34
|
+
masterSecret: Array.isArray(master.value) ? master.value.at(-1) : master.value,
|
|
35
|
+
source: master.source,
|
|
36
|
+
values,
|
|
37
|
+
derived,
|
|
38
|
+
keyring: ring.entries,
|
|
39
|
+
activeKey: ring.active,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default resolve;
|
package/server/context.js
CHANGED
|
@@ -2,15 +2,26 @@ import * as vault from './lib/vault/index.js';
|
|
|
2
2
|
import { wsBroadcastToPath, wsSendToSocket } from './ws/registry.js';
|
|
3
3
|
|
|
4
4
|
function trackDb(db) {
|
|
5
|
-
if (!db || typeof db !== 'function' && typeof db !== 'object')
|
|
5
|
+
if (!db || (typeof db !== 'function' && typeof db !== 'object'))
|
|
6
|
+
return { db, stats: { queries: 0, durationMs: 0 } };
|
|
6
7
|
const stats = { queries: 0, durationMs: 0 };
|
|
7
8
|
const handler = {
|
|
8
9
|
get(target, prop, receiver) {
|
|
9
10
|
const value = Reflect.get(target, prop, receiver);
|
|
10
11
|
if (typeof value !== 'function') return value;
|
|
11
|
-
if (
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
if (
|
|
13
|
+
prop === 'raw' ||
|
|
14
|
+
prop === 'select' ||
|
|
15
|
+
prop === 'insert' ||
|
|
16
|
+
prop === 'update' ||
|
|
17
|
+
prop === 'delete' ||
|
|
18
|
+
prop === 'del' ||
|
|
19
|
+
prop === 'from' ||
|
|
20
|
+
prop === 'into' ||
|
|
21
|
+
prop === 'table' ||
|
|
22
|
+
prop === 'with' ||
|
|
23
|
+
prop === 'withRecursive'
|
|
24
|
+
) {
|
|
14
25
|
return (...args) => {
|
|
15
26
|
const builder = value.apply(target, args);
|
|
16
27
|
return wrapBuilder(builder, stats);
|
|
@@ -101,6 +112,16 @@ function normalizeMeta(meta) {
|
|
|
101
112
|
};
|
|
102
113
|
}
|
|
103
114
|
|
|
115
|
+
function buildVaultToolkit(vaultConfig) {
|
|
116
|
+
if (!vaultConfig?.masterSecret) return vault;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
...vault,
|
|
120
|
+
deriveKey: (label) => vault.deriveKey(vaultConfig.masterSecret, label),
|
|
121
|
+
deriveKeyPair: (label, opts) => vault.deriveKeyPair(vaultConfig.masterSecret, label, opts),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
104
125
|
function buildContext(appContext, extras) {
|
|
105
126
|
const tracked = trackDb(appContext.db);
|
|
106
127
|
const normalizedExtras = { ...extras };
|
|
@@ -124,12 +145,13 @@ function buildContext(appContext, extras) {
|
|
|
124
145
|
files: appContext.files,
|
|
125
146
|
mail: appContext.mail,
|
|
126
147
|
meta: normalizeMeta(appContext.meta),
|
|
127
|
-
vault,
|
|
148
|
+
vault: buildVaultToolkit(appContext.vault),
|
|
128
149
|
ws,
|
|
129
150
|
err: (input, status) => buildErrResponse(input, status),
|
|
130
151
|
_dbStats: tracked.stats,
|
|
131
152
|
...normalizedExtras,
|
|
132
153
|
};
|
|
154
|
+
ctx.plugins = appContext.plugins?.buildContextProxy?.(ctx);
|
|
133
155
|
return ctx;
|
|
134
156
|
}
|
|
135
157
|
|
package/server/db/index.js
CHANGED
|
@@ -6,9 +6,8 @@ import { loadModule } from '../module-loader.js';
|
|
|
6
6
|
import { introspectSchema, generateSchemaMarkdown } from './schema/index.js';
|
|
7
7
|
|
|
8
8
|
class MigrationSource {
|
|
9
|
-
constructor(directory
|
|
9
|
+
constructor(directory) {
|
|
10
10
|
this.directory = directory;
|
|
11
|
-
this.pluginMigrations = pluginMigrations;
|
|
12
11
|
this.migrationPaths = new Map();
|
|
13
12
|
}
|
|
14
13
|
async getMigrations() {
|
|
@@ -23,15 +22,6 @@ class MigrationSource {
|
|
|
23
22
|
}
|
|
24
23
|
}
|
|
25
24
|
|
|
26
|
-
for (const plugin of this.pluginMigrations ?? []) {
|
|
27
|
-
const files = await discoverFiles(plugin.dir, { skipUnderscore: false });
|
|
28
|
-
for (const file of files) {
|
|
29
|
-
const name = `plugins/${plugin.pluginId}/${file.name}`;
|
|
30
|
-
migrations.push(name);
|
|
31
|
-
this.migrationPaths.set(name, file.filePath);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
25
|
return migrations;
|
|
36
26
|
}
|
|
37
27
|
getMigrationName(migration) {
|
|
@@ -98,10 +88,9 @@ async function createDB(config, { log } = {}) {
|
|
|
98
88
|
|
|
99
89
|
db.runMigrations = async () => {
|
|
100
90
|
const migrationsDir = config.dir;
|
|
101
|
-
|
|
102
|
-
if (!migrationsDir && pluginMigrations.length === 0) return;
|
|
91
|
+
if (!migrationsDir) return;
|
|
103
92
|
const [batch, migrations] = await db.migrate.latest({
|
|
104
|
-
migrationSource: new MigrationSource(migrationsDir
|
|
93
|
+
migrationSource: new MigrationSource(migrationsDir),
|
|
105
94
|
});
|
|
106
95
|
if (migrations.length > 0) {
|
|
107
96
|
log?.info(`Migrations (batch ${batch}): ${migrations.join(', ')}`);
|
|
@@ -112,12 +101,11 @@ async function createDB(config, { log } = {}) {
|
|
|
112
101
|
|
|
113
102
|
db.runRollback = async () => {
|
|
114
103
|
const migrationsDir = config.dir;
|
|
115
|
-
|
|
116
|
-
if (!migrationsDir && pluginMigrations.length === 0) {
|
|
104
|
+
if (!migrationsDir) {
|
|
117
105
|
throw new Error('No migrations dir configured');
|
|
118
106
|
}
|
|
119
107
|
const [batch, entries] = await db.migrate.rollback({
|
|
120
|
-
migrationSource: new MigrationSource(migrationsDir
|
|
108
|
+
migrationSource: new MigrationSource(migrationsDir),
|
|
121
109
|
});
|
|
122
110
|
return { batch, log: entries };
|
|
123
111
|
};
|
package/server/jobs/runner.js
CHANGED
|
@@ -46,7 +46,7 @@ class JobRunner {
|
|
|
46
46
|
|
|
47
47
|
constructor(
|
|
48
48
|
config,
|
|
49
|
-
{ db, queue, cache, files, mail, events, log, meta, workerPool, plugins } = {},
|
|
49
|
+
{ db, queue, cache, files, mail, events, log, meta, vault, workerPool, plugins } = {},
|
|
50
50
|
) {
|
|
51
51
|
this._config = config;
|
|
52
52
|
this._log = log;
|
|
@@ -55,7 +55,7 @@ class JobRunner {
|
|
|
55
55
|
staleTimeoutMs: config?.staleTimeoutMs,
|
|
56
56
|
workerPool,
|
|
57
57
|
});
|
|
58
|
-
this._appContext = { db, queue, cache, files, mail, events, log, meta, plugins };
|
|
58
|
+
this._appContext = { db, queue, cache, files, mail, events, log, meta, vault, plugins };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
get dispatcher() {
|
|
@@ -17,13 +17,17 @@ if (!parentPort) {
|
|
|
17
17
|
const moduleCache = new Map();
|
|
18
18
|
let infrastructure = null;
|
|
19
19
|
let infrastructurePromise = null;
|
|
20
|
+
let resolvedConfig = null;
|
|
20
21
|
let shuttingDown = false;
|
|
21
22
|
|
|
22
23
|
async function ensureInfrastructure() {
|
|
23
24
|
if (infrastructure) return infrastructure;
|
|
24
25
|
if (!infrastructurePromise) {
|
|
25
26
|
infrastructurePromise = resolveWorkerConfig(workerData)
|
|
26
|
-
.then((config) =>
|
|
27
|
+
.then((config) => {
|
|
28
|
+
resolvedConfig = config;
|
|
29
|
+
return createInfrastructure(config, { runMigrations: false });
|
|
30
|
+
})
|
|
27
31
|
.then((services) => {
|
|
28
32
|
infrastructure = services;
|
|
29
33
|
return services;
|
|
@@ -66,7 +70,10 @@ async function runTask(task) {
|
|
|
66
70
|
// and any future cpu-only caller) gets the raw payload — no DB spin-up.
|
|
67
71
|
if (withContext) {
|
|
68
72
|
const services = await ensureInfrastructure();
|
|
69
|
-
const ctx = buildContext(
|
|
73
|
+
const ctx = buildContext(
|
|
74
|
+
{ ...services, meta: workerData?.meta, vault: resolvedConfig?.vault },
|
|
75
|
+
{ payload },
|
|
76
|
+
);
|
|
70
77
|
return fn(ctx);
|
|
71
78
|
}
|
|
72
79
|
return fn(payload);
|
|
@@ -4,22 +4,28 @@ import { encrypt, decrypt } from './encrypt.js';
|
|
|
4
4
|
import { jwtEncode, jwtDecode } from './jwt.js';
|
|
5
5
|
import {
|
|
6
6
|
decryptWithRotation,
|
|
7
|
+
deriveKey,
|
|
8
|
+
deriveKeyPair,
|
|
7
9
|
deriveBytes,
|
|
8
10
|
deriveInfraSecret,
|
|
11
|
+
deriveInfraSecretRing,
|
|
9
12
|
deriveInfraSecrets,
|
|
10
|
-
deriveVapidKeyPair,
|
|
11
13
|
encodeMasterSecret,
|
|
12
14
|
formatVaultKey,
|
|
13
15
|
normalizeMasterSecret,
|
|
16
|
+
normalizeMasterSecretRing,
|
|
17
|
+
stableSessionPasswordId,
|
|
14
18
|
validateVaultKey,
|
|
15
19
|
} from './secrets.js';
|
|
16
20
|
export {
|
|
17
21
|
decryptWithRotation,
|
|
18
22
|
decrypt,
|
|
23
|
+
deriveKey,
|
|
24
|
+
deriveKeyPair,
|
|
19
25
|
deriveBytes,
|
|
20
26
|
deriveInfraSecret,
|
|
27
|
+
deriveInfraSecretRing,
|
|
21
28
|
deriveInfraSecrets,
|
|
22
|
-
deriveVapidKeyPair,
|
|
23
29
|
encodeMasterSecret,
|
|
24
30
|
encrypt,
|
|
25
31
|
formatVaultKey,
|
|
@@ -29,6 +35,8 @@ export {
|
|
|
29
35
|
jwtDecode,
|
|
30
36
|
jwtEncode,
|
|
31
37
|
normalizeMasterSecret,
|
|
38
|
+
normalizeMasterSecretRing,
|
|
39
|
+
stableSessionPasswordId,
|
|
32
40
|
validateVaultKey,
|
|
33
41
|
verifyPassword,
|
|
34
42
|
};
|
|
@@ -1,18 +1,11 @@
|
|
|
1
|
-
import { createECDH, hkdfSync, randomBytes } from 'node:crypto';
|
|
1
|
+
import { createECDH, createHash, hkdfSync, randomBytes } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
const MASTER_VERSION = 'v1';
|
|
4
4
|
const MASTER_BYTES = 32;
|
|
5
5
|
const VAULT_KEY_BYTES = 32;
|
|
6
6
|
const HKDF_SALT = 'arcway:infra-secrets:v1';
|
|
7
7
|
|
|
8
|
-
const SECRET_NAMES = Object.freeze([
|
|
9
|
-
'session',
|
|
10
|
-
'plugin-vault',
|
|
11
|
-
'mcp-api',
|
|
12
|
-
'pty-api',
|
|
13
|
-
'pty-server-api',
|
|
14
|
-
'vapid',
|
|
15
|
-
]);
|
|
8
|
+
const SECRET_NAMES = Object.freeze(['session', 'plugin-vault', 'mcp-api']);
|
|
16
9
|
|
|
17
10
|
function base64Url(buffer) {
|
|
18
11
|
return Buffer.from(buffer).toString('base64url');
|
|
@@ -65,6 +58,13 @@ function deriveBytes(master, namespace, length = MASTER_BYTES) {
|
|
|
65
58
|
return Buffer.from(hkdfSync('sha256', key, HKDF_SALT, `arcway:${namespace}`, length));
|
|
66
59
|
}
|
|
67
60
|
|
|
61
|
+
function deriveKey(master, label) {
|
|
62
|
+
if (!label || typeof label !== 'string') {
|
|
63
|
+
throw new Error('deriveKey label must be a non-empty string');
|
|
64
|
+
}
|
|
65
|
+
return base64Url(deriveBytes(master, `app:${label}`, MASTER_BYTES));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
68
|
function formatVaultKey(bytes) {
|
|
69
69
|
const key = Buffer.from(bytes);
|
|
70
70
|
if (key.length !== VAULT_KEY_BYTES) {
|
|
@@ -77,11 +77,15 @@ function validateVaultKey(value, label = 'plugin-vault secret') {
|
|
|
77
77
|
return formatVaultKey(decodeVersionedSecret(value, label));
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
function
|
|
80
|
+
function deriveKeyPair(master, label, { curve = 'prime256v1' } = {}) {
|
|
81
|
+
if (!label || typeof label !== 'string') {
|
|
82
|
+
throw new Error('deriveKeyPair label must be a non-empty string');
|
|
83
|
+
}
|
|
84
|
+
|
|
81
85
|
for (let i = 0; i < 32; i += 1) {
|
|
82
|
-
const privateKey = deriveBytes(master, `
|
|
86
|
+
const privateKey = deriveBytes(master, `keypair:${curve}:${label}:private:${i}`, 32);
|
|
83
87
|
try {
|
|
84
|
-
const ecdh = createECDH(
|
|
88
|
+
const ecdh = createECDH(curve);
|
|
85
89
|
ecdh.setPrivateKey(privateKey);
|
|
86
90
|
return {
|
|
87
91
|
publicKey: base64Url(ecdh.getPublicKey(null, 'uncompressed')),
|
|
@@ -91,16 +95,13 @@ function deriveVapidKeyPair(master) {
|
|
|
91
95
|
// Try the next deterministic candidate.
|
|
92
96
|
}
|
|
93
97
|
}
|
|
94
|
-
throw new Error(
|
|
98
|
+
throw new Error(`Unable to derive a valid deterministic ${curve} keypair for label "${label}"`);
|
|
95
99
|
}
|
|
96
100
|
|
|
97
101
|
function deriveInfraSecret(master, name) {
|
|
98
102
|
if (name === 'session') return base64Url(deriveBytes(master, name, 48));
|
|
99
103
|
if (name === 'plugin-vault') return formatVaultKey(deriveBytes(master, name, VAULT_KEY_BYTES));
|
|
100
104
|
if (name === 'mcp-api') return base64Url(deriveBytes(master, name, 48));
|
|
101
|
-
if (name === 'pty-api') return base64Url(deriveBytes(master, name, 48));
|
|
102
|
-
if (name === 'pty-server-api') return base64Url(deriveBytes(master, name, 48));
|
|
103
|
-
if (name === 'vapid') return deriveVapidKeyPair(master);
|
|
104
105
|
throw new Error(`Unknown Arcway infra secret namespace: ${name}`);
|
|
105
106
|
}
|
|
106
107
|
|
|
@@ -112,6 +113,47 @@ function deriveInfraSecrets(master) {
|
|
|
112
113
|
return values;
|
|
113
114
|
}
|
|
114
115
|
|
|
116
|
+
function normalizeMasterSecretRing(master) {
|
|
117
|
+
const masters = Array.isArray(master) ? master : [master];
|
|
118
|
+
const normalized = masters.filter(
|
|
119
|
+
(value) => value !== undefined && value !== null && value !== '',
|
|
120
|
+
);
|
|
121
|
+
if (normalized.length === 0) throw new Error('Arcway master secret keyring cannot be empty');
|
|
122
|
+
return normalized.map((value, index) =>
|
|
123
|
+
normalizeMasterSecret(value, `Arcway master secret keyring entry ${index + 1}`),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function stableSessionPasswordId(sessionSecret) {
|
|
128
|
+
const digest = createHash('sha256').update(`arcway:session-key-id:v1:${sessionSecret}`).digest();
|
|
129
|
+
const id = digest.readUIntBE(0, 6);
|
|
130
|
+
return String(id === 0 ? 1 : id);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function deriveInfraSecretRing(master) {
|
|
134
|
+
const masters = normalizeMasterSecretRing(master);
|
|
135
|
+
const entries = masters.map((value) => {
|
|
136
|
+
const secrets = deriveInfraSecrets(value);
|
|
137
|
+
return {
|
|
138
|
+
id: stableSessionPasswordId(secrets.session),
|
|
139
|
+
secrets,
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const seenIds = new Set();
|
|
144
|
+
for (const entry of entries) {
|
|
145
|
+
if (seenIds.has(entry.id)) {
|
|
146
|
+
throw new Error('Derived session key ids collided; rotate with different master secrets');
|
|
147
|
+
}
|
|
148
|
+
seenIds.add(entry.id);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
entries,
|
|
153
|
+
active: entries.at(-1),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
115
157
|
function decryptWithRotation(ciphertext, { current, previous, decrypt, encrypt, onRotate } = {}) {
|
|
116
158
|
if (typeof decrypt !== 'function') throw new Error('decryptWithRotation requires decrypt');
|
|
117
159
|
if (typeof encrypt !== 'function') throw new Error('decryptWithRotation requires encrypt');
|
|
@@ -138,12 +180,16 @@ function decryptWithRotation(ciphertext, { current, previous, decrypt, encrypt,
|
|
|
138
180
|
export {
|
|
139
181
|
SECRET_NAMES,
|
|
140
182
|
decryptWithRotation,
|
|
183
|
+
deriveKey,
|
|
184
|
+
deriveKeyPair,
|
|
141
185
|
deriveBytes,
|
|
142
186
|
deriveInfraSecret,
|
|
187
|
+
deriveInfraSecretRing,
|
|
143
188
|
deriveInfraSecrets,
|
|
144
|
-
deriveVapidKeyPair,
|
|
145
189
|
encodeMasterSecret,
|
|
146
190
|
formatVaultKey,
|
|
147
191
|
normalizeMasterSecret,
|
|
192
|
+
normalizeMasterSecretRing,
|
|
193
|
+
stableSessionPasswordId,
|
|
148
194
|
validateVaultKey,
|
|
149
195
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { discoverPackageDirectories } from '../discovery.js';
|
|
3
|
+
import { discoverModules, discoverPackageDirectories } from '../discovery.js';
|
|
4
4
|
import { loadModule } from '../module-loader.js';
|
|
5
5
|
import { validateManifest } from './manifest.js';
|
|
6
6
|
|
|
@@ -30,6 +30,36 @@ function getManifest(module, manifestPath) {
|
|
|
30
30
|
return manifest;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function assertPlainObject(value, label) {
|
|
34
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
35
|
+
throw new Error(`${label} must export an object`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getExports(module, exportsPath) {
|
|
40
|
+
const exported = module.default;
|
|
41
|
+
assertPlainObject(exported, `Plugin exports at ${exportsPath}`);
|
|
42
|
+
for (const [name, value] of Object.entries(exported)) {
|
|
43
|
+
if (typeof value !== 'function') {
|
|
44
|
+
throw new Error(`Plugin export ${name} at ${exportsPath} must be a function`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { ...exported };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Load every `<dir>/<name>.js` and key the default export by file basename.
|
|
51
|
+
// Used for the capabilities/ and hooks/ folder conventions.
|
|
52
|
+
async function loadConventionModules(dir, label) {
|
|
53
|
+
if (!(await exists(dir))) return {};
|
|
54
|
+
const modules = await discoverModules(dir, { label: `plugin ${label}` });
|
|
55
|
+
const map = {};
|
|
56
|
+
for (const entry of modules) {
|
|
57
|
+
const name = path.basename(entry.name, path.extname(entry.name));
|
|
58
|
+
map[name] = entry.module.default ?? entry.module;
|
|
59
|
+
}
|
|
60
|
+
return map;
|
|
61
|
+
}
|
|
62
|
+
|
|
33
63
|
async function loadPluginPackage(packageDir) {
|
|
34
64
|
const packageJsonPath = path.join(packageDir, 'package.json');
|
|
35
65
|
if (!(await exists(packageJsonPath))) {
|
|
@@ -39,6 +69,7 @@ async function loadPluginPackage(packageDir) {
|
|
|
39
69
|
|
|
40
70
|
const pluginJs = path.join(packageDir, 'plugin.js');
|
|
41
71
|
const pluginJson = path.join(packageDir, 'plugin.json');
|
|
72
|
+
const exportsJs = path.join(packageDir, 'exports.js');
|
|
42
73
|
let manifestPath;
|
|
43
74
|
let module = {};
|
|
44
75
|
let rawManifest;
|
|
@@ -50,14 +81,24 @@ async function loadPluginPackage(packageDir) {
|
|
|
50
81
|
} else if (await exists(pluginJson)) {
|
|
51
82
|
manifestPath = pluginJson;
|
|
52
83
|
rawManifest = await readJson(pluginJson, 'plugin.json');
|
|
84
|
+
} else if (packageJson.arcway && typeof packageJson.arcway === 'object') {
|
|
85
|
+
// Metadata-only plugin: the manifest lives in package.json's "arcway" block,
|
|
86
|
+
// and code is discovered by folder convention (routes/, jobs/, capabilities/, hooks/).
|
|
87
|
+
manifestPath = packageJsonPath;
|
|
88
|
+
rawManifest = {};
|
|
53
89
|
} else {
|
|
54
|
-
|
|
90
|
+
// A package without plugin.js/plugin.json and without an "arcway" block is
|
|
91
|
+
// not a plugin — skip it rather than failing discovery.
|
|
92
|
+
return null;
|
|
55
93
|
}
|
|
56
94
|
|
|
95
|
+
// package.json "arcway" supplies the base metadata; an explicit plugin.js/plugin.json
|
|
96
|
+
// manifest (legacy) wins on conflict so existing plugins behave identically.
|
|
57
97
|
const manifest = validateManifest(
|
|
58
98
|
{
|
|
59
99
|
version: packageJson.version,
|
|
60
100
|
name: packageJson.name,
|
|
101
|
+
...packageJson.arcway,
|
|
61
102
|
...rawManifest,
|
|
62
103
|
},
|
|
63
104
|
{ packageName: packageJson.name },
|
|
@@ -65,10 +106,9 @@ async function loadPluginPackage(packageDir) {
|
|
|
65
106
|
|
|
66
107
|
const routesDir = path.join(packageDir, 'routes');
|
|
67
108
|
const jobsDir = path.join(packageDir, 'jobs');
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
: path.join(packageDir, 'migrations');
|
|
109
|
+
const exportsModule = (await exists(exportsJs))
|
|
110
|
+
? await loadModule(exportsJs, import.meta.url)
|
|
111
|
+
: null;
|
|
72
112
|
|
|
73
113
|
return {
|
|
74
114
|
id: manifest.id,
|
|
@@ -77,9 +117,11 @@ async function loadPluginPackage(packageDir) {
|
|
|
77
117
|
manifest,
|
|
78
118
|
module,
|
|
79
119
|
manifestPath,
|
|
120
|
+
exports: exportsModule ? getExports(exportsModule, exportsJs) : {},
|
|
80
121
|
routesDir: (await exists(routesDir)) ? routesDir : null,
|
|
81
122
|
jobsDir: (await exists(jobsDir)) ? jobsDir : null,
|
|
82
|
-
|
|
123
|
+
capabilities: await loadConventionModules(path.join(packageDir, 'capabilities'), 'capability'),
|
|
124
|
+
hooks: await loadConventionModules(path.join(packageDir, 'hooks'), 'lifecycle hook'),
|
|
83
125
|
};
|
|
84
126
|
}
|
|
85
127
|
|
|
@@ -89,11 +131,12 @@ async function discoverPlugins(dirs) {
|
|
|
89
131
|
|
|
90
132
|
for (const dir of dirs ?? []) {
|
|
91
133
|
const packages = await discoverPackageDirectories(dir, {
|
|
92
|
-
markerFiles: ['
|
|
134
|
+
markerFiles: ['package.json'],
|
|
93
135
|
label: 'plugin',
|
|
94
136
|
});
|
|
95
137
|
for (const entry of packages) {
|
|
96
138
|
const plugin = await loadPluginPackage(entry.dir);
|
|
139
|
+
if (!plugin) continue;
|
|
97
140
|
if (seen.has(plugin.id)) {
|
|
98
141
|
throw new Error(
|
|
99
142
|
`Duplicate plugin id ${plugin.id} in ${seen.get(plugin.id)} and ${plugin.dir}`,
|
|
@@ -14,17 +14,47 @@ function pluginEnabledByDefault(plugin, enablements) {
|
|
|
14
14
|
if (Object.prototype.hasOwnProperty.call(enablements, plugin.id)) {
|
|
15
15
|
return enablements[plugin.id] === true;
|
|
16
16
|
}
|
|
17
|
-
return plugin.manifest.
|
|
17
|
+
return plugin.manifest.enabled !== false;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function capabilityFactory(plugin, capability) {
|
|
21
21
|
return (
|
|
22
|
+
plugin.capabilities?.[capability] ??
|
|
22
23
|
plugin.module.capabilities?.[capability] ??
|
|
23
24
|
plugin.module.default?.capabilities?.[capability] ??
|
|
24
25
|
plugin.module[`create${capability[0]?.toUpperCase() ?? ''}${capability.slice(1)}`]
|
|
25
26
|
);
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
function bindPluginExports(plugin, ctx) {
|
|
30
|
+
return new Proxy(
|
|
31
|
+
{},
|
|
32
|
+
{
|
|
33
|
+
get(_target, prop) {
|
|
34
|
+
if (typeof prop === 'symbol') return undefined;
|
|
35
|
+
if (prop === 'then') return undefined;
|
|
36
|
+
|
|
37
|
+
const fn = plugin.exports?.[prop];
|
|
38
|
+
if (!fn) {
|
|
39
|
+
throw new Error(`Plugin ${plugin.id} does not export function ${prop}`);
|
|
40
|
+
}
|
|
41
|
+
return (...args) => fn(ctx, ...args);
|
|
42
|
+
},
|
|
43
|
+
has(_target, prop) {
|
|
44
|
+
return typeof prop === 'string' && Object.hasOwn(plugin.exports ?? {}, prop);
|
|
45
|
+
},
|
|
46
|
+
ownKeys() {
|
|
47
|
+
return Object.keys(plugin.exports ?? {});
|
|
48
|
+
},
|
|
49
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
50
|
+
if (typeof prop !== 'string' || !Object.hasOwn(plugin.exports ?? {}, prop))
|
|
51
|
+
return undefined;
|
|
52
|
+
return { enumerable: true, configurable: true };
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
28
58
|
class PluginManager {
|
|
29
59
|
constructor(config, { log, runLifecycle = true } = {}) {
|
|
30
60
|
this.config = config ?? { enabled: false, dirs: [] };
|
|
@@ -96,12 +126,27 @@ class PluginManager {
|
|
|
96
126
|
return this.capabilities.inject(ctx, requires);
|
|
97
127
|
}
|
|
98
128
|
|
|
129
|
+
exportsFor(id, ctx) {
|
|
130
|
+
const plugin = this.getPlugin(id);
|
|
131
|
+
if (!plugin) throw new Error(`Unknown plugin: ${id}`);
|
|
132
|
+
if (this.status.get(plugin.id) !== 'enabled') {
|
|
133
|
+
throw new Error(`Plugin ${plugin.id} is not enabled`);
|
|
134
|
+
}
|
|
135
|
+
return bindPluginExports(plugin, ctx);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
buildContextProxy(ctx) {
|
|
139
|
+
return {
|
|
140
|
+
get: (id) => this.exportsFor(id, ctx),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
99
144
|
async discoverRoutes() {
|
|
100
145
|
const routes = [];
|
|
101
146
|
for (const plugin of this.enabledPlugins) {
|
|
102
147
|
if (!plugin.routesDir) continue;
|
|
103
148
|
const discovered = await discoverRoutes(plugin.routesDir, {
|
|
104
|
-
prefix: `/
|
|
149
|
+
prefix: `/_plugins/${plugin.id}`,
|
|
105
150
|
plugin: {
|
|
106
151
|
id: plugin.id,
|
|
107
152
|
requires: plugin.manifest.requires,
|
|
@@ -132,16 +177,6 @@ class PluginManager {
|
|
|
132
177
|
return jobs;
|
|
133
178
|
}
|
|
134
179
|
|
|
135
|
-
migrations() {
|
|
136
|
-
return this.enabledPlugins
|
|
137
|
-
.filter((plugin) => plugin.migrationsDir)
|
|
138
|
-
.map((plugin) => ({
|
|
139
|
-
pluginId: plugin.id,
|
|
140
|
-
dir: plugin.migrationsDir,
|
|
141
|
-
contributes: plugin.manifest.contributes.migrations,
|
|
142
|
-
}));
|
|
143
|
-
}
|
|
144
|
-
|
|
145
180
|
#rebuildGraph() {
|
|
146
181
|
const providers = buildProviderMap(this.plugins);
|
|
147
182
|
for (const plugin of this.plugins) {
|
|
@@ -190,7 +225,7 @@ class PluginManager {
|
|
|
190
225
|
}
|
|
191
226
|
|
|
192
227
|
async #runLifecycle(plugin, name) {
|
|
193
|
-
const hook = plugin.manifest.lifecycle?.[name];
|
|
228
|
+
const hook = plugin.hooks?.[name] ?? plugin.manifest.lifecycle?.[name];
|
|
194
229
|
if (!hook) return;
|
|
195
230
|
const fn =
|
|
196
231
|
typeof hook === 'function' ? hook : (plugin.module[hook] ?? plugin.module.default?.[hook]);
|
|
@@ -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,19 +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
|
-
migrations: value.migrations,
|
|
34
|
-
ui: value.ui,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
23
|
function normalizeLifecycle(value) {
|
|
39
24
|
if (value === undefined) return {};
|
|
40
25
|
assertPlainObject(value, 'plugin lifecycle');
|
|
@@ -53,17 +38,34 @@ function normalizeLifecycle(value) {
|
|
|
53
38
|
function validateManifest(rawManifest, { packageName } = {}) {
|
|
54
39
|
assertPlainObject(rawManifest, 'plugin manifest');
|
|
55
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
|
+
|
|
56
60
|
const manifest = {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
contributes: normalizeContributes(rawManifest.contributes),
|
|
66
|
-
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),
|
|
67
69
|
};
|
|
68
70
|
|
|
69
71
|
if (typeof manifest.id !== 'string' || !ID_PATTERN.test(manifest.id)) {
|
|
@@ -75,13 +77,6 @@ function validateManifest(rawManifest, { packageName } = {}) {
|
|
|
75
77
|
if (typeof manifest.version !== 'string' || manifest.version === '') {
|
|
76
78
|
throw new Error(`plugin ${manifest.id} version must be a non-empty string`);
|
|
77
79
|
}
|
|
78
|
-
if (!KINDS.has(manifest.kind)) {
|
|
79
|
-
throw new Error(`plugin ${manifest.id} kind must be core or feature`);
|
|
80
|
-
}
|
|
81
|
-
if (!SCOPES.has(manifest.scope)) {
|
|
82
|
-
throw new Error(`plugin ${manifest.id} scope must be global or workspace`);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
80
|
return manifest;
|
|
86
81
|
}
|
|
87
82
|
|
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;
|