arcway 0.2.1 → 0.3.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.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,9 @@ import { createViteDevRouter } from '../pages/vite-dev.js';
19
19
  import { resolvePagesOutDir } from '../pages/out-dir.js';
20
20
  import { collectRuntimeMeta } from '../meta.js';
21
21
  import { createPluginManager } from '../plugins/manager.js';
22
+ import { CallbackDispatcher, CallbackRegistry } from '../callbacks/index.js';
23
+ import { CallbackStore } from '../callbacks/store.js';
24
+ import { discoverRootCallbacks } from '../callbacks/discovery.js';
22
25
  import path from 'node:path';
23
26
 
24
27
  async function boot(options) {
@@ -35,6 +38,12 @@ async function boot(options) {
35
38
 
36
39
  const infrastructure = await createInfrastructure(config, { runMigrations: true });
37
40
  const { db, redis, queue, cache, files, mail, events, log } = infrastructure;
41
+ const callbackStore = new CallbackStore(db);
42
+ await callbackStore.init();
43
+ const callbacks = new CallbackRegistry({
44
+ store: callbackStore,
45
+ secret: config.vault?.values?.callback,
46
+ });
38
47
  const meta = await collectRuntimeMeta(rootDir);
39
48
 
40
49
  if (envFiles.length > 0) log.info('Env files loaded', { envFiles });
@@ -58,6 +67,7 @@ async function boot(options) {
58
67
  fileWatcher,
59
68
  meta,
60
69
  vault: config.vault,
70
+ callbacks,
61
71
  };
62
72
 
63
73
  plugins.log = log;
@@ -98,6 +108,7 @@ async function boot(options) {
98
108
  log,
99
109
  meta,
100
110
  vault: config.vault,
111
+ callbacks,
101
112
  workerPool,
102
113
  plugins,
103
114
  });
@@ -113,7 +124,7 @@ async function boot(options) {
113
124
  sessionConfig: config.session,
114
125
  });
115
126
  await apiRouter.init();
116
- if (fileWatcher) plugins.watch(fileWatcher, { apiRouter, jobRunner });
127
+ if (fileWatcher) plugins.watch(fileWatcher, { jobRunner });
117
128
 
118
129
  const pagesRouter = new PagesRouter(config, { rootDir, log, mode, fileWatcher, appContext });
119
130
  await pagesRouter.init();
@@ -128,7 +139,17 @@ async function boot(options) {
128
139
  db,
129
140
  redisClients: [{ name: 'redis', client: redis?.client }],
130
141
  };
131
- const systemRouter = new SystemRouter({ healthDeps });
142
+ const callbackHandlers = await discoverRootCallbacks(rootDir);
143
+ const pluginCallbacks = (await plugins.discoverCallbacks?.()) ?? new Map();
144
+ for (const [name, callback] of pluginCallbacks) callbackHandlers.set(name, callback);
145
+ const callbackDispatcher = new CallbackDispatcher({
146
+ appContext,
147
+ callbacks: callbackHandlers,
148
+ registry: callbacks,
149
+ sessionConfig: config.session,
150
+ log,
151
+ });
152
+ const systemRouter = new SystemRouter({ healthDeps, callbackDispatcher });
132
153
 
133
154
  const staticRouter = new StaticRouter({
134
155
  outDir: pagesOutDir,
package/server/build.js CHANGED
@@ -28,7 +28,6 @@ async function validateServerModules(config) {
28
28
  if (config?.api?.enabled !== false && config?.api?.dir) {
29
29
  await discoverRoutes(config.api.dir);
30
30
  await discoverMiddleware(config.api.dir);
31
- await plugins.discoverRoutes();
32
31
  }
33
32
 
34
33
  if (config?.events?.dir) {
@@ -0,0 +1,12 @@
1
+ import { CallbackStore } from './store.js';
2
+
3
+ const cleanupCallbacksJob = {
4
+ name: 'cleanup-callbacks',
5
+ schedule: '*/15 * * * *',
6
+ handler: async (ctx) => {
7
+ const store = new CallbackStore(ctx.db);
8
+ await store.cleanup();
9
+ },
10
+ };
11
+
12
+ export { cleanupCallbacksJob };
@@ -0,0 +1,33 @@
1
+ import path from 'node:path';
2
+ import { discoverModules } from '../discovery.js';
3
+
4
+ function callbackName(relativePath) {
5
+ return relativePath.replace(/\\/g, '/').replace(/\.js$/, '');
6
+ }
7
+
8
+ function buildCallbacksFromModules(entries, { prefix = '' } = {}) {
9
+ const callbacks = new Map();
10
+ for (const entry of entries) {
11
+ const handler = entry.module.default ?? entry.module.handler;
12
+ if (typeof handler !== 'function') {
13
+ throw new Error(`Callback at ${entry.filePath} must export a handler function`);
14
+ }
15
+ const name = `${prefix}${callbackName(entry.relativePath)}`;
16
+ callbacks.set(name, { name, filePath: entry.filePath, handler });
17
+ }
18
+ return callbacks;
19
+ }
20
+
21
+ async function discoverCallbacks(callbacksDir) {
22
+ const entries = await discoverModules(callbacksDir, {
23
+ recursive: true,
24
+ label: 'callback file',
25
+ });
26
+ return buildCallbacksFromModules(entries);
27
+ }
28
+
29
+ async function discoverRootCallbacks(rootDir) {
30
+ return discoverCallbacks(path.join(rootDir, 'callbacks'));
31
+ }
32
+
33
+ export { buildCallbacksFromModules, discoverCallbacks, discoverRootCallbacks };
@@ -0,0 +1,173 @@
1
+ import { serializeResponse, sendJson } from '../router/http-helpers.js';
2
+ import { buildSessionClearCookie, buildSessionSetCookie, sealSession } from '../session/index.js';
3
+ import { buildContext } from '../context.js';
4
+ import { CallbackRateLimiter } from './rate-limit.js';
5
+ import { sealCallbackToken, unsealCallbackToken } from './tokens.js';
6
+
7
+ function getCallbackToken(req, params) {
8
+ const state = req.query?.state ?? params.query?.get?.('state');
9
+ if (state) return state;
10
+ const pathToken = params.path && params.path !== '/' ? params.path.replace(/^\/+/, '') : '';
11
+ return pathToken ? decodeURIComponent(pathToken) : null;
12
+ }
13
+
14
+ function buildReqInfo(req) {
15
+ return {
16
+ id: req.id,
17
+ ip: req.ip,
18
+ method: req.method ?? 'GET',
19
+ path: (req.url ?? '/').split('?')[0],
20
+ query: req.query ?? {},
21
+ body: req.body,
22
+ rawBody: req.rawBody,
23
+ headers: req.flatHeaders ?? {},
24
+ cookies: req.cookies ?? {},
25
+ session: req.session,
26
+ };
27
+ }
28
+
29
+ class CallbackRegistry {
30
+ constructor({ store, secret }) {
31
+ this.store = store;
32
+ this.secret = secret;
33
+ }
34
+
35
+ bind(ctx) {
36
+ return {
37
+ register: async ({ handler, payload, oneTime = true, ttl } = {}) => {
38
+ if (!this.secret) throw new Error('Callback key is required');
39
+ const boundToSession =
40
+ oneTime !== false ? (ctx.req?.session?.id ?? ctx.req?.session?.userId ?? null) : null;
41
+ const row = await this.store.register({
42
+ handler,
43
+ payload,
44
+ oneTime,
45
+ ttl,
46
+ boundToSession,
47
+ });
48
+ return sealCallbackToken({ id: row.id, expiresAt: row.expiresAt }, this.secret);
49
+ },
50
+ };
51
+ }
52
+ }
53
+
54
+ class CallbackDispatcher {
55
+ constructor({ appContext, callbacks, registry, sessionConfig, log, rateLimiter } = {}) {
56
+ this.appContext = appContext;
57
+ this.callbacks = callbacks ?? new Map();
58
+ this.registry = registry;
59
+ this.sessionConfig = sessionConfig ?? null;
60
+ this.log = log;
61
+ this.rateLimiter = rateLimiter ?? new CallbackRateLimiter();
62
+ }
63
+
64
+ async handle(req, res, params) {
65
+ const rate = this.rateLimiter.check(req.ip);
66
+ if (!rate.allowed) {
67
+ sendJson(
68
+ res,
69
+ 429,
70
+ { error: { code: 'RATE_LIMITED', message: 'Callback rate limit exceeded' } },
71
+ {
72
+ 'X-RateLimit-Remaining': '0',
73
+ 'X-RateLimit-Reset': String(rate.resetAt),
74
+ 'Retry-After': String(Math.max(1, rate.resetAt - Math.ceil(Date.now() / 1000))),
75
+ },
76
+ );
77
+ return;
78
+ }
79
+
80
+ const token = getCallbackToken(req, params);
81
+ if (!token) {
82
+ sendJson(res, 400, {
83
+ error: { code: 'CALLBACK_TOKEN_REQUIRED', message: 'Callback token is required' },
84
+ });
85
+ return;
86
+ }
87
+
88
+ let sealed;
89
+ try {
90
+ sealed = await unsealCallbackToken(token, this.registry.secret);
91
+ } catch {
92
+ sendJson(res, 400, {
93
+ error: { code: 'INVALID_CALLBACK_TOKEN', message: 'Invalid callback token' },
94
+ });
95
+ return;
96
+ }
97
+
98
+ const row = await this.registry.store.consume(sealed.id);
99
+ if (!row) {
100
+ sendJson(res, 410, {
101
+ error: { code: 'CALLBACK_EXPIRED_OR_USED', message: 'Callback expired or already used' },
102
+ });
103
+ return;
104
+ }
105
+
106
+ if (row.boundToSession) {
107
+ const sessionId = req.session?.id ?? req.session?.userId ?? null;
108
+ if (sessionId !== row.boundToSession) {
109
+ sendJson(res, 403, {
110
+ error: { code: 'CALLBACK_SESSION_MISMATCH', message: 'Callback session mismatch' },
111
+ });
112
+ return;
113
+ }
114
+ }
115
+
116
+ const target = this.callbacks.get(row.target);
117
+ if (!target) {
118
+ sendJson(res, 404, {
119
+ error: { code: 'CALLBACK_HANDLER_NOT_FOUND', message: 'Callback handler not found' },
120
+ });
121
+ return;
122
+ }
123
+
124
+ const ctx = buildContext(
125
+ {
126
+ ...this.appContext,
127
+ log: this.log?.extend ? this.log.extend({ requestId: req.id }) : this.appContext.log,
128
+ },
129
+ {
130
+ req: buildReqInfo(req),
131
+ callback: { payload: row.payload, target: row.target, id: row.id },
132
+ },
133
+ );
134
+
135
+ let response;
136
+ try {
137
+ response = await target.handler(ctx);
138
+ } catch (err) {
139
+ this.log?.error?.('Callback handler error', { target: row.target, error: String(err) });
140
+ sendJson(res, 500, {
141
+ error: { code: 'CALLBACK_HANDLER_ERROR', message: 'Callback handler error' },
142
+ });
143
+ return;
144
+ }
145
+
146
+ const responseHeaders = { ...(response?.headers ?? {}) };
147
+ if (response?.redirect) {
148
+ res.writeHead(response.status ?? 302, { ...responseHeaders, Location: response.redirect });
149
+ res.end();
150
+ return;
151
+ }
152
+ if (response?.session !== void 0) {
153
+ if (!this.sessionConfig) {
154
+ throw new Error('Callback handler returned session but no session is configured');
155
+ }
156
+ responseHeaders['Set-Cookie'] =
157
+ response.session === null
158
+ ? buildSessionClearCookie(this.sessionConfig)
159
+ : buildSessionSetCookie(
160
+ await sealSession(response.session, this.sessionConfig),
161
+ this.sessionConfig,
162
+ );
163
+ }
164
+ await serializeResponse(
165
+ res,
166
+ response ?? { data: null },
167
+ responseHeaders,
168
+ response?.status ?? 200,
169
+ );
170
+ }
171
+ }
172
+
173
+ export { CallbackDispatcher, CallbackRegistry };
@@ -0,0 +1,32 @@
1
+ const DEFAULT_CALLBACK_RATE_LIMIT = Object.freeze({
2
+ max: 30,
3
+ windowMs: 60_000,
4
+ });
5
+
6
+ class CallbackRateLimiter {
7
+ constructor({
8
+ max = DEFAULT_CALLBACK_RATE_LIMIT.max,
9
+ windowMs = DEFAULT_CALLBACK_RATE_LIMIT.windowMs,
10
+ } = {}) {
11
+ this.max = max;
12
+ this.windowMs = windowMs;
13
+ this.buckets = new Map();
14
+ }
15
+
16
+ check(ip = 'unknown', now = Date.now()) {
17
+ const key = String(ip || 'unknown');
18
+ let bucket = this.buckets.get(key);
19
+ if (!bucket || bucket.resetAt <= now) {
20
+ bucket = { count: 0, resetAt: now + this.windowMs };
21
+ this.buckets.set(key, bucket);
22
+ }
23
+ bucket.count += 1;
24
+ return {
25
+ allowed: bucket.count <= this.max,
26
+ remaining: Math.max(0, this.max - bucket.count),
27
+ resetAt: Math.ceil(bucket.resetAt / 1000),
28
+ };
29
+ }
30
+ }
31
+
32
+ export { CallbackRateLimiter, DEFAULT_CALLBACK_RATE_LIMIT };
@@ -0,0 +1,121 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ const CALLBACK_TABLE = 'arcway_callbacks';
4
+ const DEFAULT_CALLBACK_TTL_SECONDS = 600;
5
+ const DEFAULT_CLEANUP_GRACE_SECONDS = 3600;
6
+
7
+ function toIso(value) {
8
+ return new Date(value).toISOString();
9
+ }
10
+
11
+ function serializePayload(payload) {
12
+ return JSON.stringify(payload ?? null);
13
+ }
14
+
15
+ function parseRow(row) {
16
+ if (!row) return null;
17
+ return {
18
+ id: row.id,
19
+ target: row.target,
20
+ payload: JSON.parse(row.payload),
21
+ oneTime: Boolean(row.one_time),
22
+ used: Boolean(row.used),
23
+ boundToSession: row.bound_to_session ?? null,
24
+ expiresAt: row.expires_at,
25
+ };
26
+ }
27
+
28
+ class CallbackStore {
29
+ constructor(db, { tableName = CALLBACK_TABLE } = {}) {
30
+ this.db = db;
31
+ this.tableName = tableName;
32
+ }
33
+
34
+ async init() {
35
+ if (!this.db) return;
36
+ const exists = await this.db.schema.hasTable(this.tableName);
37
+ if (exists) return;
38
+ await this.db.schema.createTable(this.tableName, (table) => {
39
+ table.string('id').primary();
40
+ table.string('target').notNullable().index();
41
+ table.text('payload').notNullable();
42
+ table.boolean('one_time').notNullable().defaultTo(true);
43
+ table.boolean('used').notNullable().defaultTo(false).index();
44
+ table.string('bound_to_session').nullable().index();
45
+ table.timestamp('expires_at').notNullable().index();
46
+ table.timestamp('created_at').defaultTo(this.db.fn.now());
47
+ table.timestamp('updated_at').defaultTo(this.db.fn.now());
48
+ });
49
+ }
50
+
51
+ async register({ handler, payload, oneTime = true, ttl, boundToSession }) {
52
+ if (!this.db) throw new Error('Callback registration requires a database');
53
+ if (!handler || typeof handler !== 'string') {
54
+ throw new Error('Callback handler must be a non-empty string');
55
+ }
56
+ const ttlSeconds = ttl ?? DEFAULT_CALLBACK_TTL_SECONDS;
57
+ if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
58
+ throw new Error('Callback ttl must be a positive number of seconds');
59
+ }
60
+
61
+ const now = Date.now();
62
+ const row = {
63
+ id: randomUUID(),
64
+ target: handler,
65
+ payload: serializePayload(payload),
66
+ one_time: oneTime !== false,
67
+ used: false,
68
+ bound_to_session: boundToSession ?? null,
69
+ expires_at: toIso(now + ttlSeconds * 1000),
70
+ created_at: toIso(now),
71
+ updated_at: toIso(now),
72
+ };
73
+ await this.db(this.tableName).insert(row);
74
+ return parseRow(row);
75
+ }
76
+
77
+ async claim(id, now = new Date()) {
78
+ const nowIso = toIso(now);
79
+ return this.db.transaction(async (trx) => {
80
+ const updated = await trx(this.tableName)
81
+ .where('id', id)
82
+ .andWhere('one_time', true)
83
+ .andWhere('used', false)
84
+ .andWhere('expires_at', '>', nowIso)
85
+ .update({ used: true, updated_at: nowIso });
86
+ if (updated === 0) return null;
87
+ const row = await trx(this.tableName).where('id', id).first();
88
+ return parseRow(row);
89
+ });
90
+ }
91
+
92
+ async getActive(id, now = new Date()) {
93
+ const row = await this.db(this.tableName)
94
+ .where('id', id)
95
+ .andWhere('one_time', false)
96
+ .andWhere('expires_at', '>', toIso(now))
97
+ .first();
98
+ if (!row || row.used) return null;
99
+ return parseRow(row);
100
+ }
101
+
102
+ async consume(id, now = new Date()) {
103
+ const claimed = await this.claim(id, now);
104
+ if (claimed) return claimed;
105
+ return this.getActive(id, now);
106
+ }
107
+
108
+ async cleanup({ graceSeconds = DEFAULT_CLEANUP_GRACE_SECONDS } = {}) {
109
+ const now = Date.now();
110
+ const expiredCutoff = toIso(now);
111
+ const usedCutoff = toIso(now - graceSeconds * 1000);
112
+ return this.db(this.tableName)
113
+ .where('expires_at', '<=', expiredCutoff)
114
+ .orWhere(function () {
115
+ this.where('used', true).andWhere('updated_at', '<=', usedCutoff);
116
+ })
117
+ .delete();
118
+ }
119
+ }
120
+
121
+ export { CALLBACK_TABLE, CallbackStore };
@@ -0,0 +1,44 @@
1
+ import { sealData, unsealData } from 'iron-session';
2
+
3
+ function requireCallbackKey(secret) {
4
+ if (!secret) {
5
+ throw new Error('Callback key is required');
6
+ }
7
+ return secret;
8
+ }
9
+
10
+ async function sealCallbackToken({ id, expiresAt }, secret) {
11
+ const exp = new Date(expiresAt).getTime();
12
+ if (!id || !Number.isFinite(exp)) {
13
+ throw new Error('Callback token requires id and expiresAt');
14
+ }
15
+ return sealData(
16
+ { id, exp },
17
+ {
18
+ password: requireCallbackKey(secret),
19
+ ttl: 0,
20
+ },
21
+ );
22
+ }
23
+
24
+ async function unsealCallbackToken(token, secret, now = Date.now()) {
25
+ let data;
26
+ try {
27
+ data = await unsealData(token, {
28
+ password: requireCallbackKey(secret),
29
+ ttl: 0,
30
+ });
31
+ } catch {
32
+ throw new Error('Invalid callback token');
33
+ }
34
+
35
+ if (!data || typeof data !== 'object' || typeof data.id !== 'string') {
36
+ throw new Error('Invalid callback token');
37
+ }
38
+ if (!Number.isFinite(data.exp) || data.exp <= now) {
39
+ throw new Error('Callback token expired');
40
+ }
41
+ return { id: data.id, exp: data.exp };
42
+ }
43
+
44
+ export { sealCallbackToken, unsealCallbackToken };
package/server/context.js CHANGED
@@ -154,6 +154,7 @@ function buildContext(appContext, extras) {
154
154
  _dbStats: tracked.stats,
155
155
  ...normalizedExtras,
156
156
  };
157
+ ctx.callbacks = appContext.callbacks?.bind?.(ctx);
157
158
  ctx.plugins = appContext.plugins?.buildContextProxy?.(ctx);
158
159
  return ctx;
159
160
  }
@@ -51,7 +51,20 @@ class JobRunner {
51
51
 
52
52
  constructor(
53
53
  config,
54
- { db, queue, cache, files, mail, events, log, meta, vault, workerPool, plugins } = {},
54
+ {
55
+ db,
56
+ queue,
57
+ cache,
58
+ files,
59
+ mail,
60
+ events,
61
+ log,
62
+ meta,
63
+ vault,
64
+ callbacks,
65
+ workerPool,
66
+ plugins,
67
+ } = {},
55
68
  ) {
56
69
  this._config = config;
57
70
  this._log = log;
@@ -60,7 +73,19 @@ class JobRunner {
60
73
  staleTimeoutMs: config?.staleTimeoutMs,
61
74
  workerPool,
62
75
  });
63
- this._appContext = { db, queue, cache, files, mail, events, log, meta, vault, plugins };
76
+ this._appContext = {
77
+ db,
78
+ queue,
79
+ cache,
80
+ files,
81
+ mail,
82
+ events,
83
+ log,
84
+ meta,
85
+ vault,
86
+ callbacks,
87
+ plugins,
88
+ };
64
89
  }
65
90
 
66
91
  get dispatcher() {
@@ -4,6 +4,8 @@ import { serializeError } from './worker-pool.js';
4
4
  import { resolveWorkerConfig } from './worker-config.js';
5
5
  import { buildContext } from '../context.js';
6
6
  import { createInfrastructure, destroyInfrastructure } from '../boot/infrastructure.js';
7
+ import { CallbackRegistry } from '../callbacks/index.js';
8
+ import { CallbackStore } from '../callbacks/store.js';
7
9
 
8
10
  // Worker-side entry for the jobs WorkerPool. Each worker owns its own DB /
9
11
  // redis / queue / cache / files / mail / events / log — built lazily on the
@@ -70,8 +72,14 @@ async function runTask(task) {
70
72
  // and any future cpu-only caller) gets the raw payload — no DB spin-up.
71
73
  if (withContext) {
72
74
  const services = await ensureInfrastructure();
75
+ const callbackStore = new CallbackStore(services.db);
76
+ await callbackStore.init();
77
+ const callbacks = new CallbackRegistry({
78
+ store: callbackStore,
79
+ secret: resolvedConfig?.vault?.values?.callback,
80
+ });
73
81
  const ctx = buildContext(
74
- { ...services, meta: workerData?.meta, vault: resolvedConfig?.vault },
82
+ { ...services, meta: workerData?.meta, vault: resolvedConfig?.vault, callbacks },
75
83
  { payload },
76
84
  );
77
85
  return fn(ctx);
@@ -5,7 +5,7 @@ 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(['session', 'plugin-vault', 'mcp-api', 'jwt']);
8
+ const SECRET_NAMES = Object.freeze(['session', 'plugin-vault', 'mcp-api', 'jwt', 'callback']);
9
9
 
10
10
  function base64Url(buffer) {
11
11
  return Buffer.from(buffer).toString('base64url');
@@ -103,6 +103,7 @@ function deriveInfraSecret(master, name) {
103
103
  if (name === 'plugin-vault') return formatVaultKey(deriveBytes(master, name, VAULT_KEY_BYTES));
104
104
  if (name === 'mcp-api') return base64Url(deriveBytes(master, name, 48));
105
105
  if (name === 'jwt') return base64Url(deriveBytes(master, name, 48));
106
+ if (name === 'callback') return base64Url(deriveBytes(master, name, 48));
106
107
  throw new Error(`Unknown Arcway infra secret namespace: ${name}`);
107
108
  }
108
109
 
@@ -32,15 +32,15 @@ async function loadPluginBundle(packageDir) {
32
32
 
33
33
  const pluginJs = path.join(packageDir, 'plugin.js');
34
34
  const exportsJs = path.join(packageDir, 'exports.js');
35
- const routesDir = path.join(packageDir, 'routes');
36
35
  const jobsDir = path.join(packageDir, 'jobs');
36
+ const callbacksDir = path.join(packageDir, 'callbacks');
37
37
  const capabilitiesDir = path.join(packageDir, 'capabilities');
38
38
  const hooksDir = path.join(packageDir, 'hooks');
39
39
 
40
40
  const hasPluginJs = await exists(pluginJs);
41
41
  const hasExportsJs = await exists(exportsJs);
42
- const routes = await discoverFiles(routesDir, { recursive: true });
43
42
  const jobs = await discoverFiles(jobsDir, { recursive: true });
43
+ const callbacks = await discoverFiles(callbacksDir, { recursive: true });
44
44
  const capabilities = await discoverFiles(capabilitiesDir);
45
45
  const hooks = await discoverFiles(hooksDir);
46
46
 
@@ -48,12 +48,12 @@ async function loadPluginBundle(packageDir) {
48
48
  if (hasPluginJs) lines.push(importLine('pluginModule', pluginJs));
49
49
  if (hasExportsJs) lines.push(importLine('pluginExports', exportsJs, false));
50
50
 
51
- for (const [index, entry] of routes.entries()) {
52
- lines.push(importLine(`route${index}`, entry.filePath));
53
- }
54
51
  for (const [index, entry] of jobs.entries()) {
55
52
  lines.push(importLine(`job${index}`, entry.filePath));
56
53
  }
54
+ for (const [index, entry] of callbacks.entries()) {
55
+ lines.push(importLine(`callback${index}`, entry.filePath));
56
+ }
57
57
  for (const [index, entry] of capabilities.entries()) {
58
58
  lines.push(importLine(`capability${index}`, entry.filePath, false));
59
59
  }
@@ -66,10 +66,10 @@ async function loadPluginBundle(packageDir) {
66
66
  `export const exportsModule = ${hasExportsJs ? '{ default: pluginExports }' : 'null'};`,
67
67
  );
68
68
  lines.push(
69
- `export const routes = [${routes.map((entry, index) => moduleEntry(`route${index}`, entry)).join(',')}];`,
69
+ `export const jobs = [${jobs.map((entry, index) => moduleEntry(`job${index}`, entry)).join(',')}];`,
70
70
  );
71
71
  lines.push(
72
- `export const jobs = [${jobs.map((entry, index) => moduleEntry(`job${index}`, entry)).join(',')}];`,
72
+ `export const callbacks = [${callbacks.map((entry, index) => moduleEntry(`callback${index}`, entry)).join(',')}];`,
73
73
  );
74
74
  lines.push(
75
75
  `export const capabilities = {${capabilities
@@ -107,8 +107,8 @@ async function loadPluginBundle(packageDir) {
107
107
  return {
108
108
  module: bundled.module ?? {},
109
109
  exportsModule: bundled.exportsModule ?? null,
110
- routes: bundled.routes ?? [],
111
110
  jobs: bundled.jobs ?? [],
111
+ callbacks: bundled.callbacks ?? [],
112
112
  capabilities: bundled.capabilities ?? {},
113
113
  hooks: bundled.hooks ?? {},
114
114
  artifact: outFile,
@@ -90,7 +90,7 @@ async function loadPluginPackage(packageDir, { bundle = false } = {}) {
90
90
  rawManifest = await readJson(pluginJson, 'plugin.json');
91
91
  } else if (packageJson.arcway && typeof packageJson.arcway === 'object') {
92
92
  // Metadata-only plugin: the manifest lives in package.json's "arcway" block,
93
- // and code is discovered by folder convention (routes/, jobs/, capabilities/, hooks/).
93
+ // and code is discovered by folder convention (jobs/, callbacks/, capabilities/, hooks/).
94
94
  manifestPath = packageJsonPath;
95
95
  rawManifest = {};
96
96
  } else {
@@ -111,8 +111,8 @@ async function loadPluginPackage(packageDir, { bundle = false } = {}) {
111
111
  { packageName: packageJson.name },
112
112
  );
113
113
 
114
- const routesDir = path.join(packageDir, 'routes');
115
114
  const jobsDir = path.join(packageDir, 'jobs');
115
+ const callbacksDir = path.join(packageDir, 'callbacks');
116
116
  if (bundle && !bundled) bundled = await loadPluginBundle(packageDir);
117
117
  const exportsModule = bundled
118
118
  ? bundled.exportsModule
@@ -129,10 +129,10 @@ async function loadPluginPackage(packageDir, { bundle = false } = {}) {
129
129
  bundle: bundled ? { artifact: bundled.artifact } : null,
130
130
  manifestPath,
131
131
  exports: exportsModule ? getExports(exportsModule, exportsJs) : {},
132
- routesDir: (await exists(routesDir)) ? routesDir : null,
133
132
  jobsDir: (await exists(jobsDir)) ? jobsDir : null,
134
- bundledRoutes: bundled?.routes ?? null,
133
+ callbacksDir: (await exists(callbacksDir)) ? callbacksDir : null,
135
134
  bundledJobs: bundled?.jobs ?? null,
135
+ bundledCallbacks: bundled?.callbacks ?? null,
136
136
  capabilities:
137
137
  bundled?.capabilities ??
138
138
  (await loadConventionModules(path.join(packageDir, 'capabilities'), 'capability')),
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
- import { buildRoutesFromModules, discoverRoutes } from '../router/routes.js';
3
2
  import { buildJobsFromModules, discoverJobs } from '../jobs/runner.js';
3
+ import { buildCallbacksFromModules, discoverCallbacks } from '../callbacks/discovery.js';
4
4
  import { CapabilityRegistry } from './capabilities.js';
5
5
  import { discoverPlugins } from './discovery.js';
6
6
  import { buildProviderMap, cascadeDisabled, resolvePluginGraph } from './graph.js';
@@ -166,26 +166,6 @@ class PluginManager {
166
166
  };
167
167
  }
168
168
 
169
- async discoverRoutes() {
170
- const routes = [];
171
- for (const plugin of this.enabledPlugins) {
172
- const routeOptions = {
173
- prefix: `/_plugins/${plugin.id}`,
174
- plugin: {
175
- id: plugin.id,
176
- requires: plugin.manifest.requires,
177
- },
178
- };
179
- const discovered = plugin.bundledRoutes
180
- ? buildRoutesFromModules(plugin.bundledRoutes, routeOptions)
181
- : plugin.routesDir
182
- ? await discoverRoutes(plugin.routesDir, routeOptions)
183
- : [];
184
- routes.push(...discovered);
185
- }
186
- return routes;
187
- }
188
-
189
169
  async discoverJobs() {
190
170
  const jobs = [];
191
171
  for (const plugin of this.enabledPlugins) {
@@ -209,6 +189,22 @@ class PluginManager {
209
189
  return jobs;
210
190
  }
211
191
 
192
+ async discoverCallbacks() {
193
+ const callbacks = new Map();
194
+ for (const plugin of this.enabledPlugins) {
195
+ const discovered = plugin.bundledCallbacks
196
+ ? buildCallbacksFromModules(plugin.bundledCallbacks)
197
+ : plugin.callbacksDir
198
+ ? await discoverCallbacks(plugin.callbacksDir)
199
+ : new Map();
200
+ for (const [name, callback] of discovered) {
201
+ const target = `plugins/${plugin.id}/${name}`;
202
+ callbacks.set(target, { ...callback, name: target, plugin });
203
+ }
204
+ }
205
+ return callbacks;
206
+ }
207
+
212
208
  async reload(ids) {
213
209
  if (this.config.enabled !== true) return { reloaded: [] };
214
210
 
@@ -298,7 +294,7 @@ class PluginManager {
298
294
  return ids;
299
295
  }
300
296
 
301
- watch(fileWatcher, { apiRouter, jobRunner } = {}) {
297
+ watch(fileWatcher, { jobRunner } = {}) {
302
298
  if (this.config.enabled !== true || !fileWatcher) return;
303
299
  fileWatcher.subscribe('plugins', {
304
300
  dirs: this.config.dirs,
@@ -307,7 +303,6 @@ class PluginManager {
307
303
  onChange: async (events) => {
308
304
  const changedIds = this.pluginIdsForChangedFiles(events);
309
305
  await this.reload(changedIds);
310
- await apiRouter?.reload?.();
311
306
  await jobRunner?.reload?.();
312
307
  },
313
308
  });
@@ -39,7 +39,6 @@ class ApiRouter {
39
39
  _appContext;
40
40
  _sessionConfig;
41
41
  _redis;
42
- _pluginManager;
43
42
  _routes = [];
44
43
  _middleware = [];
45
44
  _prefix = '';
@@ -54,7 +53,6 @@ class ApiRouter {
54
53
  this._appContext = appContext ?? null;
55
54
  this._sessionConfig = sessionConfig ?? null;
56
55
  this._redis = appContext?.redis ?? null;
57
- this._pluginManager = appContext?.plugins ?? null;
58
56
  }
59
57
 
60
58
  get routes() {
@@ -103,7 +101,6 @@ class ApiRouter {
103
101
  const chainedHandler = buildMiddlewareChain(middlewareFns, route.config.handler);
104
102
  const ctx = this._buildCtx(reqInfo);
105
103
  try {
106
- if (route.plugin) this._pluginManager?.injectCapabilities(ctx, route.plugin.requires);
107
104
  return await chainedHandler(ctx);
108
105
  } catch (err) {
109
106
  this._log.error(`Handler error in ${route.method} ${route.pattern}`, {
@@ -265,7 +262,6 @@ class ApiRouter {
265
262
 
266
263
  let response;
267
264
  try {
268
- if (route.plugin) this._pluginManager?.injectCapabilities(ctx, route.plugin.requires);
269
265
  response = await chainedHandler(ctx);
270
266
  } catch (err) {
271
267
  const errorMsg = toErrorMessage(err);
@@ -358,12 +354,10 @@ class ApiRouter {
358
354
  }
359
355
 
360
356
  async _discover() {
361
- const [routes, middleware, pluginRoutes] = await Promise.all([
357
+ const [routes, middleware] = await Promise.all([
362
358
  discoverRoutes(this._config.dir),
363
359
  discoverMiddleware(this._config.dir),
364
- this._pluginManager?.discoverRoutes?.() ?? [],
365
360
  ]);
366
- routes.push(...pluginRoutes);
367
361
  sortBySpecificity(routes);
368
362
 
369
363
  applyPrefix(routes, middleware, this._prefix);
@@ -1,5 +1,12 @@
1
+ import { cleanupCallbacksJob } from '../callbacks/cleanup-job.js';
2
+
1
3
  const SYSTEM_JOB_DOMAIN = '__system';
2
4
  const systemJobRegistry = [
5
+ {
6
+ definition: cleanupCallbacksJob,
7
+ shouldRegister: (config) => Boolean(config.vault?.values?.callback && config.database),
8
+ description: 'Delete expired callback registrations',
9
+ },
3
10
  // Future system jobs are added here. Examples:
4
11
  //
5
12
  // {
@@ -5,9 +5,11 @@ const SYSTEM_PREFIX = '/_system/api';
5
5
  class SystemRouter {
6
6
  routes = [];
7
7
  _healthDeps = null;
8
+ _callbackDispatcher = null;
8
9
 
9
- constructor({ healthDeps } = {}) {
10
+ constructor({ healthDeps, callbackDispatcher } = {}) {
10
11
  this._healthDeps = healthDeps ?? null;
12
+ this._callbackDispatcher = callbackDispatcher ?? null;
11
13
  }
12
14
 
13
15
  register(route) {
@@ -23,6 +25,18 @@ class SystemRouter {
23
25
  return this._handleHealth(res);
24
26
  }
25
27
 
28
+ const pathname = url.split('?')[0];
29
+ if (
30
+ this._callbackDispatcher &&
31
+ (pathname === '/_system/callback' || pathname.startsWith('/_system/callback/'))
32
+ ) {
33
+ const fullPath = pathname;
34
+ const path = fullPath.slice('/_system/callback'.length) || '/';
35
+ const query = new URL(url, 'http://localhost').searchParams;
36
+ await this._callbackDispatcher.handle(req, res, { path, query, method });
37
+ return true;
38
+ }
39
+
26
40
  if (!url.startsWith(SYSTEM_PREFIX)) return false;
27
41
  const fullPath = url.split('?')[0];
28
42
  const routePath = fullPath.slice(SYSTEM_PREFIX.length) || '/';