arcway 0.1.30 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.1.30",
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",
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
- const FILES = {
5
- 'arcway.config.js': `export default {
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
- 'api/index.js': `export const GET = {
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
- 'api/users/index.js': `export const GET = {
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
- 'api/users/[id].js': `export const GET = {
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
- 'listeners/users/created.js': `export default async (ctx) => {
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
- 'jobs/cleanup-sessions.js': `export default {
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
- 'migrations/202601010000-create-users.js': `export async function up(knex) {
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
- 'pages/index.jsx': `export default function Home() {
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
- '.env': `# NODE_ENV=development
94
- # SESSION_PASSWORD=change-me-to-a-random-32-char-string
98
+ '.env': `# NODE_ENV=development
99
+ ARCWAY_MASTER_SECRET=${masterSecret}
95
100
  `,
96
101
 
97
- '.gitignore': `node_modules/
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(FILES)) {
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;
@@ -43,7 +43,19 @@ async function boot(options) {
43
43
  // framework services and to the init/ready/shutdown hooks. Mutations from
44
44
  // the init hook (e.g. wrapping `appContext.db`) persist for process lifetime
45
45
  // because downstream consumers read properties off this reference at use time.
46
- const appContext = { db, redis, events, queue, cache, files, mail, log, fileWatcher, meta };
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
+ };
47
59
 
48
60
  plugins.log = log;
49
61
  appContext.plugins = plugins;
@@ -82,6 +94,7 @@ async function boot(options) {
82
94
  events,
83
95
  log,
84
96
  meta,
97
+ vault: config.vault,
85
98
  workerPool,
86
99
  plugins,
87
100
  });
@@ -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 resolveSecrets from './modules/secrets.js';
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
- resolveSecrets,
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.secrets?.values?.['mcp-api']) {
8
- mcp.secret = config.secrets.values['mcp-api'];
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: config.session.password || config.secrets?.values?.session,
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') return { db, stats: { queries: 0, durationMs: 0 } };
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 (prop === 'raw' || prop === 'select' || prop === 'insert' || prop === 'update' ||
12
- prop === 'delete' || prop === 'del' || prop === 'from' || prop === 'into' ||
13
- prop === 'table' || prop === 'with' || prop === 'withRecursive') {
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
 
@@ -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) => createInfrastructure(config, { runMigrations: false }))
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({ ...services, meta: workerData?.meta }, { payload });
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 deriveVapidKeyPair(master) {
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, `vapid:private:${i}`, 32);
86
+ const privateKey = deriveBytes(master, `keypair:${curve}:${label}:private:${i}`, 32);
83
87
  try {
84
- const ecdh = createECDH('prime256v1');
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('Unable to derive a valid deterministic VAPID keypair');
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
  };
@@ -30,6 +30,23 @@ 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
+
33
50
  // Load every `<dir>/<name>.js` and key the default export by file basename.
34
51
  // Used for the capabilities/ and hooks/ folder conventions.
35
52
  async function loadConventionModules(dir, label) {
@@ -52,6 +69,7 @@ async function loadPluginPackage(packageDir) {
52
69
 
53
70
  const pluginJs = path.join(packageDir, 'plugin.js');
54
71
  const pluginJson = path.join(packageDir, 'plugin.json');
72
+ const exportsJs = path.join(packageDir, 'exports.js');
55
73
  let manifestPath;
56
74
  let module = {};
57
75
  let rawManifest;
@@ -88,6 +106,9 @@ async function loadPluginPackage(packageDir) {
88
106
 
89
107
  const routesDir = path.join(packageDir, 'routes');
90
108
  const jobsDir = path.join(packageDir, 'jobs');
109
+ const exportsModule = (await exists(exportsJs))
110
+ ? await loadModule(exportsJs, import.meta.url)
111
+ : null;
91
112
 
92
113
  return {
93
114
  id: manifest.id,
@@ -96,6 +117,7 @@ async function loadPluginPackage(packageDir) {
96
117
  manifest,
97
118
  module,
98
119
  manifestPath,
120
+ exports: exportsModule ? getExports(exportsModule, exportsJs) : {},
99
121
  routesDir: (await exists(routesDir)) ? routesDir : null,
100
122
  jobsDir: (await exists(jobsDir)) ? jobsDir : null,
101
123
  capabilities: await loadConventionModules(path.join(packageDir, 'capabilities'), 'capability'),
@@ -14,7 +14,7 @@ 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.defaultEnabled === true;
17
+ return plugin.manifest.enabled !== false;
18
18
  }
19
19
 
20
20
  function capabilityFactory(plugin, capability) {
@@ -26,6 +26,35 @@ function capabilityFactory(plugin, capability) {
26
26
  );
27
27
  }
28
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
+
29
58
  class PluginManager {
30
59
  constructor(config, { log, runLifecycle = true } = {}) {
31
60
  this.config = config ?? { enabled: false, dirs: [] };
@@ -97,12 +126,27 @@ class PluginManager {
97
126
  return this.capabilities.inject(ctx, requires);
98
127
  }
99
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
+
100
144
  async discoverRoutes() {
101
145
  const routes = [];
102
146
  for (const plugin of this.enabledPlugins) {
103
147
  if (!plugin.routesDir) continue;
104
148
  const discovered = await discoverRoutes(plugin.routesDir, {
105
- prefix: `/plugins/${plugin.id}`,
149
+ prefix: `/_plugins/${plugin.id}`,
106
150
  plugin: {
107
151
  id: plugin.id,
108
152
  requires: plugin.manifest.requires,
@@ -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
 
@@ -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;