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 +1 -1
- package/server/boot/index.js +23 -2
- package/server/build.js +0 -1
- package/server/callbacks/cleanup-job.js +12 -0
- package/server/callbacks/discovery.js +33 -0
- package/server/callbacks/index.js +173 -0
- package/server/callbacks/rate-limit.js +32 -0
- package/server/callbacks/store.js +121 -0
- package/server/callbacks/tokens.js +44 -0
- package/server/context.js +1 -0
- package/server/jobs/runner.js +27 -2
- package/server/jobs/worker-entry.js +9 -1
- package/server/lib/vault/secrets.js +2 -1
- package/server/plugins/bundle.js +8 -8
- package/server/plugins/discovery.js +4 -4
- package/server/plugins/manager.js +18 -23
- package/server/router/api-router.js +1 -7
- package/server/system-jobs/index.js +7 -0
- package/server/system-routes/index.js +15 -1
package/package.json
CHANGED
package/server/boot/index.js
CHANGED
|
@@ -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, {
|
|
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
|
|
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
package/server/jobs/runner.js
CHANGED
|
@@ -51,7 +51,20 @@ class JobRunner {
|
|
|
51
51
|
|
|
52
52
|
constructor(
|
|
53
53
|
config,
|
|
54
|
-
{
|
|
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 = {
|
|
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
|
|
package/server/plugins/bundle.js
CHANGED
|
@@ -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
|
|
69
|
+
`export const jobs = [${jobs.map((entry, index) => moduleEntry(`job${index}`, entry)).join(',')}];`,
|
|
70
70
|
);
|
|
71
71
|
lines.push(
|
|
72
|
-
`export const
|
|
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 (
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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) || '/';
|