arcway 0.2.0 → 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 +28 -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 +4 -0
- package/server/jobs/runner.js +54 -10
- package/server/jobs/worker-entry.js +9 -1
- package/server/lib/vault/jwt.js +34 -21
- package/server/lib/vault/secrets.js +3 -1
- package/server/plugins/bundle.js +122 -0
- package/server/plugins/discovery.js +29 -12
- package/server/plugins/graph.js +22 -8
- package/server/plugins/manager.js +158 -22
- package/server/router/api-router.js +5 -7
- package/server/router/routes.js +7 -2
- 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) {
|
|
@@ -28,10 +31,19 @@ async function boot(options) {
|
|
|
28
31
|
const envFiles = loadEnvFiles(rootDir, mode);
|
|
29
32
|
const config = await makeConfig(rootDir, { overrides: options.configOverrides, mode });
|
|
30
33
|
|
|
31
|
-
const plugins = await createPluginManager(config.plugins, {
|
|
34
|
+
const plugins = await createPluginManager(config.plugins, {
|
|
35
|
+
runLifecycle: false,
|
|
36
|
+
bundle: mode === 'development',
|
|
37
|
+
});
|
|
32
38
|
|
|
33
39
|
const infrastructure = await createInfrastructure(config, { runMigrations: true });
|
|
34
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
|
+
});
|
|
35
47
|
const meta = await collectRuntimeMeta(rootDir);
|
|
36
48
|
|
|
37
49
|
if (envFiles.length > 0) log.info('Env files loaded', { envFiles });
|
|
@@ -55,6 +67,7 @@ async function boot(options) {
|
|
|
55
67
|
fileWatcher,
|
|
56
68
|
meta,
|
|
57
69
|
vault: config.vault,
|
|
70
|
+
callbacks,
|
|
58
71
|
};
|
|
59
72
|
|
|
60
73
|
plugins.log = log;
|
|
@@ -95,6 +108,7 @@ async function boot(options) {
|
|
|
95
108
|
log,
|
|
96
109
|
meta,
|
|
97
110
|
vault: config.vault,
|
|
111
|
+
callbacks,
|
|
98
112
|
workerPool,
|
|
99
113
|
plugins,
|
|
100
114
|
});
|
|
@@ -110,6 +124,7 @@ async function boot(options) {
|
|
|
110
124
|
sessionConfig: config.session,
|
|
111
125
|
});
|
|
112
126
|
await apiRouter.init();
|
|
127
|
+
if (fileWatcher) plugins.watch(fileWatcher, { jobRunner });
|
|
113
128
|
|
|
114
129
|
const pagesRouter = new PagesRouter(config, { rootDir, log, mode, fileWatcher, appContext });
|
|
115
130
|
await pagesRouter.init();
|
|
@@ -124,7 +139,17 @@ async function boot(options) {
|
|
|
124
139
|
db,
|
|
125
140
|
redisClients: [{ name: 'redis', client: redis?.client }],
|
|
126
141
|
};
|
|
127
|
-
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 });
|
|
128
153
|
|
|
129
154
|
const staticRouter = new StaticRouter({
|
|
130
155
|
outDir: pagesOutDir,
|
|
@@ -166,6 +191,7 @@ async function boot(options) {
|
|
|
166
191
|
if (workerPool) await workerPool.shutdown();
|
|
167
192
|
await pagesRouter.close();
|
|
168
193
|
await apiRouter.close();
|
|
194
|
+
if (fileWatcher) plugins.unwatch(fileWatcher);
|
|
169
195
|
if (viteRouter) await viteRouter.close();
|
|
170
196
|
await webServer.close();
|
|
171
197
|
if (fileWatcher) await fileWatcher.close();
|
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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as vault from './lib/vault/index.js';
|
|
2
|
+
import { createJwtCodec } from './lib/vault/jwt.js';
|
|
2
3
|
import { wsBroadcastToPath, wsSendToSocket } from './ws/registry.js';
|
|
3
4
|
|
|
4
5
|
function trackDb(db) {
|
|
@@ -114,9 +115,11 @@ function normalizeMeta(meta) {
|
|
|
114
115
|
|
|
115
116
|
function buildVaultToolkit(vaultConfig) {
|
|
116
117
|
if (!vaultConfig?.masterSecret) return vault;
|
|
118
|
+
const jwtCodec = createJwtCodec(vaultConfig.values?.jwt);
|
|
117
119
|
|
|
118
120
|
return {
|
|
119
121
|
...vault,
|
|
122
|
+
...jwtCodec,
|
|
120
123
|
deriveKey: (label) => vault.deriveKey(vaultConfig.masterSecret, label),
|
|
121
124
|
deriveKeyPair: (label, opts) => vault.deriveKeyPair(vaultConfig.masterSecret, label, opts),
|
|
122
125
|
};
|
|
@@ -151,6 +154,7 @@ function buildContext(appContext, extras) {
|
|
|
151
154
|
_dbStats: tracked.stats,
|
|
152
155
|
...normalizedExtras,
|
|
153
156
|
};
|
|
157
|
+
ctx.callbacks = appContext.callbacks?.bind?.(ctx);
|
|
154
158
|
ctx.plugins = appContext.plugins?.buildContextProxy?.(ctx);
|
|
155
159
|
return ctx;
|
|
156
160
|
}
|
package/server/jobs/runner.js
CHANGED
|
@@ -5,8 +5,7 @@ import { toError, calculateBackoff } from './queue.js';
|
|
|
5
5
|
import JobDispatcher from './drivers/memory-queue.js';
|
|
6
6
|
import { SYSTEM_JOB_DOMAIN, registerSystemJobs } from '../system-jobs/index.js';
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
const entries = await discoverModules(jobsDir, { recursive: true, label: 'job file' });
|
|
8
|
+
function buildJobsFromModules(entries) {
|
|
10
9
|
const jobs = [];
|
|
11
10
|
for (const { name, filePath, relativePath, module } of entries) {
|
|
12
11
|
const job = module.default;
|
|
@@ -31,6 +30,11 @@ async function discoverJobs(jobsDir) {
|
|
|
31
30
|
return jobs;
|
|
32
31
|
}
|
|
33
32
|
|
|
33
|
+
async function discoverJobs(jobsDir) {
|
|
34
|
+
const entries = await discoverModules(jobsDir, { recursive: true, label: 'job file' });
|
|
35
|
+
return buildJobsFromModules(entries);
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
class JobRunner {
|
|
35
39
|
_dispatcher;
|
|
36
40
|
_config;
|
|
@@ -42,11 +46,25 @@ class JobRunner {
|
|
|
42
46
|
_timer = null;
|
|
43
47
|
_started = false;
|
|
44
48
|
_stopped = false;
|
|
49
|
+
_runGeneration = 0;
|
|
45
50
|
_lastEnqueuedMinute = new Map();
|
|
46
51
|
|
|
47
52
|
constructor(
|
|
48
53
|
config,
|
|
49
|
-
{
|
|
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
|
+
} = {},
|
|
50
68
|
) {
|
|
51
69
|
this._config = config;
|
|
52
70
|
this._log = log;
|
|
@@ -55,7 +73,19 @@ class JobRunner {
|
|
|
55
73
|
staleTimeoutMs: config?.staleTimeoutMs,
|
|
56
74
|
workerPool,
|
|
57
75
|
});
|
|
58
|
-
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
|
+
};
|
|
59
89
|
}
|
|
60
90
|
|
|
61
91
|
get dispatcher() {
|
|
@@ -135,10 +165,23 @@ class JobRunner {
|
|
|
135
165
|
}
|
|
136
166
|
}
|
|
137
167
|
|
|
168
|
+
async reload() {
|
|
169
|
+
const wasStarted = this._started;
|
|
170
|
+
this.stop();
|
|
171
|
+
this._jobs = [];
|
|
172
|
+
this._cronEntries = [];
|
|
173
|
+
this._continuousJobs = [];
|
|
174
|
+
this._lastEnqueuedMinute = new Map();
|
|
175
|
+
this._dispatcher.registered.clear();
|
|
176
|
+
await this.init();
|
|
177
|
+
if (wasStarted) this.start();
|
|
178
|
+
}
|
|
179
|
+
|
|
138
180
|
start(intervalMs) {
|
|
139
181
|
if (this._started) return;
|
|
140
182
|
this._started = true;
|
|
141
183
|
this._stopped = false;
|
|
184
|
+
const generation = ++this._runGeneration;
|
|
142
185
|
const pollMs = intervalMs ?? this._config?.pollIntervalMs ?? 60000;
|
|
143
186
|
this._timer = setInterval(() => {
|
|
144
187
|
this.tick().catch((err) => {
|
|
@@ -146,7 +189,7 @@ class JobRunner {
|
|
|
146
189
|
});
|
|
147
190
|
}, pollMs);
|
|
148
191
|
for (const entry of this._continuousJobs) {
|
|
149
|
-
this._runContinuousLoop(entry);
|
|
192
|
+
this._runContinuousLoop(entry, generation);
|
|
150
193
|
}
|
|
151
194
|
|
|
152
195
|
const parts = [];
|
|
@@ -158,6 +201,7 @@ class JobRunner {
|
|
|
158
201
|
stop() {
|
|
159
202
|
this._stopped = true;
|
|
160
203
|
this._started = false;
|
|
204
|
+
this._runGeneration++;
|
|
161
205
|
if (this._timer) {
|
|
162
206
|
clearInterval(this._timer);
|
|
163
207
|
this._timer = null;
|
|
@@ -188,12 +232,12 @@ class JobRunner {
|
|
|
188
232
|
await this._dispatcher.process();
|
|
189
233
|
}
|
|
190
234
|
|
|
191
|
-
async _runContinuousLoop(entry) {
|
|
235
|
+
async _runContinuousLoop(entry, generation) {
|
|
192
236
|
const backoffMs = this._config?.backoffMs ?? 1000;
|
|
193
237
|
const cooldownMs = entry.cooldownMs ?? this._config?.cooldownMs ?? 1000;
|
|
194
238
|
let consecutiveErrors = 0;
|
|
195
239
|
console.log(`[job-runner] continuous: ${entry.qualifiedName} started`);
|
|
196
|
-
while (!this._stopped) {
|
|
240
|
+
while (!this._stopped && generation === this._runGeneration) {
|
|
197
241
|
try {
|
|
198
242
|
const start = Date.now();
|
|
199
243
|
const ctx = buildContext(entry.appContext, { payload: void 0 });
|
|
@@ -206,7 +250,7 @@ class JobRunner {
|
|
|
206
250
|
if (cooldownMs > 0 && elapsed < cooldownMs) {
|
|
207
251
|
const remaining = cooldownMs - elapsed;
|
|
208
252
|
const deadline = Date.now() + remaining;
|
|
209
|
-
while (!this._stopped && Date.now() < deadline) {
|
|
253
|
+
while (!this._stopped && generation === this._runGeneration && Date.now() < deadline) {
|
|
210
254
|
await new Promise((r) => setTimeout(r, Math.min(100, deadline - Date.now())));
|
|
211
255
|
}
|
|
212
256
|
}
|
|
@@ -219,7 +263,7 @@ class JobRunner {
|
|
|
219
263
|
error.message,
|
|
220
264
|
);
|
|
221
265
|
const deadline = Date.now() + delay;
|
|
222
|
-
while (!this._stopped && Date.now() < deadline) {
|
|
266
|
+
while (!this._stopped && generation === this._runGeneration && Date.now() < deadline) {
|
|
223
267
|
await new Promise((r) => setTimeout(r, Math.min(100, deadline - Date.now())));
|
|
224
268
|
}
|
|
225
269
|
}
|
|
@@ -228,4 +272,4 @@ class JobRunner {
|
|
|
228
272
|
}
|
|
229
273
|
}
|
|
230
274
|
|
|
231
|
-
export { JobRunner, discoverJobs };
|
|
275
|
+
export { JobRunner, buildJobsFromModules, discoverJobs };
|