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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.1.29",
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
- 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;
@@ -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 = { 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
+ };
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
  });
@@ -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
 
@@ -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, pluginMigrations = []) {
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
- const pluginMigrations = config.pluginMigrations ?? [];
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, pluginMigrations),
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
- const pluginMigrations = config.pluginMigrations ?? [];
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, pluginMigrations),
108
+ migrationSource: new MigrationSource(migrationsDir),
121
109
  });
122
110
  return { batch, log: entries };
123
111
  };
@@ -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
  };
@@ -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
- throw new Error(`Plugin package at ${packageDir} must include plugin.js or plugin.json`);
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 migrationsDir =
69
- typeof manifest.contributes.migrations === 'string'
70
- ? path.join(packageDir, manifest.contributes.migrations)
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
- migrationsDir: (await exists(migrationsDir)) ? migrationsDir : null,
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: ['plugin.js', 'plugin.json'],
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.defaultEnabled === true;
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: `/plugins/${plugin.id}`,
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
- id: rawManifest.id ?? packageName,
58
- name: rawManifest.name ?? rawManifest.id ?? packageName,
59
- version: rawManifest.version,
60
- kind: rawManifest.kind ?? 'feature',
61
- scope: rawManifest.scope ?? 'workspace',
62
- defaultEnabled: rawManifest.defaultEnabled === true,
63
- provides: normalizeStringArray(rawManifest.provides, 'plugin provides'),
64
- requires: normalizeRequires(rawManifest.requires),
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
 
@@ -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;