@zhin.js/host-http 1.0.10 → 1.0.12
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/lib/console-events.d.ts +18 -4
- package/lib/console-events.js +121 -9
- package/lib/console-rest-pages.d.ts +15 -5
- package/lib/console-rest-pages.js +162 -16
- package/lib/console-rpc-extended.d.ts +92 -2
- package/lib/console-rpc-extended.js +243 -16
- package/lib/console-rpc.d.ts +10 -0
- package/lib/console-rpc.js +48 -0
- package/lib/http-host.d.ts +2 -2
- package/lib/http-host.js +4 -3
- package/lib/index.d.ts +2 -2
- package/lib/token-registry.d.ts +7 -0
- package/lib/token-registry.js +29 -8
- package/package.json +3 -3
- package/src/console-events.ts +170 -12
- package/src/console-rest-pages.ts +181 -20
- package/src/console-rpc-extended.ts +332 -18
- package/src/console-rpc.ts +55 -0
- package/src/http-host.ts +7 -4
- package/src/index.ts +3 -0
- package/src/token-registry.ts +36 -8
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* 数据源约定(只读真实数据源,不做内存假数据):
|
|
9
9
|
* - cron:`ctx.scheduleHost`(Plugin Runtime ScheduleHost,仅 register/list,
|
|
10
10
|
* 无持久化引擎,故 cron:add/remove/pause/resume 报"未接线")。
|
|
11
|
-
* -
|
|
11
|
+
* - request.list:优先 `management.listRequests()`(live endpoint);否则回退
|
|
12
|
+
* unified_inbox_request。审批始终走 approveRequest/rejectRequest。
|
|
13
|
+
* - inbox(历史):`ctx.databaseHost.models`(unified_inbox_message/request/notice,
|
|
12
14
|
* 表不存在或查询失败 → 空数组 + inboxEnabled:false;requestConsumed/noticeConsumed
|
|
13
15
|
* 按行 id update consumed=1,表/model 缺失时报"未接线")。
|
|
14
16
|
* - 社交/群管:`ctx.withEndpointManagement` 在固定 generation lease 内
|
|
@@ -19,6 +21,7 @@ import { assertDemoConsoleRpcAllowed, normalizeConsoleRpcType, } from '@zhin.js/
|
|
|
19
21
|
const TABLE_MESSAGE = 'unified_inbox_message';
|
|
20
22
|
const TABLE_REQUEST = 'unified_inbox_request';
|
|
21
23
|
const TABLE_NOTICE = 'unified_inbox_notice';
|
|
24
|
+
const MAX_INBOX_OFFSET = 10_000;
|
|
22
25
|
const CRON_NOT_WIRED = '持久化调度未接线:当前 Plugin Runtime ScheduleHost 仅支持插件注册的内存任务(list),' +
|
|
23
26
|
'不支持 add/remove/pause/resume(需要 @zhin.js/agent 持久化调度引擎)';
|
|
24
27
|
const CONSUMED_NOT_WIRED = '收件箱已读标记未接线:unified_inbox_request/notice 表未注册或 DatabaseHost 未启动';
|
|
@@ -51,6 +54,12 @@ export async function dispatchExtendedConsoleRpc(type, data, ctx) {
|
|
|
51
54
|
case 'request.approve':
|
|
52
55
|
case 'request.reject':
|
|
53
56
|
return actOnRequest(type, d, ctx);
|
|
57
|
+
case 'login.list':
|
|
58
|
+
return listLoginPending(ctx);
|
|
59
|
+
case 'login.submit':
|
|
60
|
+
return submitLogin(d, ctx);
|
|
61
|
+
case 'login.cancel':
|
|
62
|
+
return cancelLogin(d, ctx);
|
|
54
63
|
case 'request.consumed':
|
|
55
64
|
return markInboxConsumed(d, ctx, TABLE_REQUEST);
|
|
56
65
|
case 'notice.consumed':
|
|
@@ -86,10 +95,126 @@ export async function dispatchExtendedConsoleRpc(type, data, ctx) {
|
|
|
86
95
|
});
|
|
87
96
|
case 'endpoint.delete_friend':
|
|
88
97
|
return deleteFriend(d, ctx);
|
|
98
|
+
case 'workroom.profile.pack.publish':
|
|
99
|
+
case 'workroom.profile.publish':
|
|
100
|
+
case 'workroom.profile.rollback':
|
|
101
|
+
case 'workroom.profile.policy.publish':
|
|
102
|
+
return mutateWorkroomProfile(type, d, ctx);
|
|
103
|
+
case 'workroom.knowledge.get':
|
|
104
|
+
case 'workroom.knowledge.publish':
|
|
105
|
+
case 'workroom.knowledge.rollback':
|
|
106
|
+
return mutateWorkroomKnowledge(type, d, ctx);
|
|
89
107
|
default:
|
|
90
108
|
return undefined;
|
|
91
109
|
}
|
|
92
110
|
}
|
|
111
|
+
async function mutateWorkroomProfile(type, data, ctx) {
|
|
112
|
+
const control = ctx.workroomProfileControl;
|
|
113
|
+
if (!control)
|
|
114
|
+
return { error: 'Workroom Profile authority control is unavailable' };
|
|
115
|
+
for (const forbidden of [
|
|
116
|
+
'authenticatedPrincipalId', 'principalId', 'decision', 'governance', 'approval', 'authority',
|
|
117
|
+
]) {
|
|
118
|
+
if (forbidden in data)
|
|
119
|
+
return { error: `Console Workroom Profile request cannot carry ${forbidden}` };
|
|
120
|
+
}
|
|
121
|
+
const principal = ctx.authenticatedPrincipal;
|
|
122
|
+
if (!principal)
|
|
123
|
+
return { error: 'Workroom Profile control requires an authenticated principal' };
|
|
124
|
+
try {
|
|
125
|
+
const operationId = requiredRpcText(data.operationId, 'operationId');
|
|
126
|
+
if (type === 'workroom.profile.pack.publish') {
|
|
127
|
+
return { data: await control.publishPack(Object.freeze({ operationId, pack: requiredRpcValue(data.pack, 'pack') }), principal) };
|
|
128
|
+
}
|
|
129
|
+
const projectId = requiredRpcText(data.projectId, 'projectId');
|
|
130
|
+
if (type === 'workroom.profile.policy.publish') {
|
|
131
|
+
const expectedPreviousDigest = optionalRpcText(data.expectedPreviousDigest, 'expectedPreviousDigest');
|
|
132
|
+
return { data: await control.publishPlanningPolicy(Object.freeze({
|
|
133
|
+
operationId,
|
|
134
|
+
projectId,
|
|
135
|
+
profileRevisionId: requiredRpcText(data.profileRevisionId, 'profileRevisionId'),
|
|
136
|
+
revision: requiredRpcInteger(data.revision, 'revision'),
|
|
137
|
+
...(expectedPreviousDigest ? { expectedPreviousDigest } : {}),
|
|
138
|
+
policy: requiredRpcValue(data.policy, 'policy'),
|
|
139
|
+
}), principal) };
|
|
140
|
+
}
|
|
141
|
+
const common = {
|
|
142
|
+
operationId,
|
|
143
|
+
projectId,
|
|
144
|
+
expectedRegistryRevision: requiredRpcInteger(data.expectedRegistryRevision, 'expectedRegistryRevision', -1),
|
|
145
|
+
overlay: requiredRpcValue(data.overlay, 'overlay'),
|
|
146
|
+
activate: requiredRpcBoolean(data.activate, 'activate'),
|
|
147
|
+
};
|
|
148
|
+
return type === 'workroom.profile.rollback'
|
|
149
|
+
? { data: await control.publishRollback(Object.freeze({
|
|
150
|
+
...common,
|
|
151
|
+
restoredFromRevisionId: requiredRpcText(data.restoredFromRevisionId, 'restoredFromRevisionId'),
|
|
152
|
+
}), principal) }
|
|
153
|
+
: { data: await control.publishProfile(Object.freeze(common), principal) };
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
return { error: errorMessage(error) };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function mutateWorkroomKnowledge(type, data, ctx) {
|
|
160
|
+
const control = ctx.workroomKnowledgeControl;
|
|
161
|
+
if (!control)
|
|
162
|
+
return { error: 'Workroom Knowledge control is unavailable' };
|
|
163
|
+
for (const forbidden of [
|
|
164
|
+
'authenticatedPrincipalId', 'principalId', 'ownerPrincipalId', 'source', 'decision',
|
|
165
|
+
'governance', 'approval', 'authority', 'body', 'content',
|
|
166
|
+
]) {
|
|
167
|
+
if (forbidden in data)
|
|
168
|
+
return { error: `Console Workroom Knowledge request cannot carry ${forbidden}` };
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const projectId = requiredRpcText(data.projectId, 'projectId');
|
|
172
|
+
if (type === 'workroom.knowledge.get')
|
|
173
|
+
return { data: await control.read(projectId) };
|
|
174
|
+
const principal = ctx.authenticatedPrincipal;
|
|
175
|
+
if (!principal)
|
|
176
|
+
return { error: 'Workroom Knowledge mutation requires an authenticated principal' };
|
|
177
|
+
const common = {
|
|
178
|
+
operationId: requiredRpcText(data.operationId, 'operationId'), projectId,
|
|
179
|
+
expectedRevision: requiredRpcInteger(data.expectedRevision, 'expectedRevision', -1),
|
|
180
|
+
};
|
|
181
|
+
if (type === 'workroom.knowledge.rollback') {
|
|
182
|
+
return { data: await control.rollback(Object.freeze({
|
|
183
|
+
...common, restoreRevision: requiredRpcInteger(data.restoreRevision, 'restoreRevision', 0),
|
|
184
|
+
}), principal) };
|
|
185
|
+
}
|
|
186
|
+
if (!Array.isArray(data.entries))
|
|
187
|
+
throw new Error('entries is invalid');
|
|
188
|
+
return { data: await control.publish(Object.freeze({ ...common, entries: structuredClone(data.entries) }), principal) };
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
return { error: errorMessage(error) };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function requiredRpcValue(value, name) {
|
|
195
|
+
if (value === undefined || value === null)
|
|
196
|
+
throw new Error(`${name} is required`);
|
|
197
|
+
return structuredClone(value);
|
|
198
|
+
}
|
|
199
|
+
function requiredRpcText(value, name) {
|
|
200
|
+
if (typeof value !== 'string' || !value.trim() || value !== value.trim()) {
|
|
201
|
+
throw new Error(`${name} is invalid`);
|
|
202
|
+
}
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
205
|
+
function optionalRpcText(value, name) {
|
|
206
|
+
return value === undefined ? undefined : requiredRpcText(value, name);
|
|
207
|
+
}
|
|
208
|
+
function requiredRpcInteger(value, name, minimum = 1) {
|
|
209
|
+
if (!Number.isSafeInteger(value) || Number(value) < minimum)
|
|
210
|
+
throw new Error(`${name} is invalid`);
|
|
211
|
+
return Number(value);
|
|
212
|
+
}
|
|
213
|
+
function requiredRpcBoolean(value, name) {
|
|
214
|
+
if (typeof value !== 'boolean')
|
|
215
|
+
throw new Error(`${name} is invalid`);
|
|
216
|
+
return value;
|
|
217
|
+
}
|
|
93
218
|
// ---------------------------------------------------------------- cron
|
|
94
219
|
function listSchedule(ctx) {
|
|
95
220
|
const host = ctx.scheduleHost;
|
|
@@ -206,21 +331,30 @@ function getInboxModel(ctx, table) {
|
|
|
206
331
|
async function readInboxRows(ctx, table, where, options) {
|
|
207
332
|
const model = getInboxModel(ctx, table);
|
|
208
333
|
if (!model)
|
|
209
|
-
return { rows: [], enabled: false };
|
|
334
|
+
return { rows: [], enabled: false, offsetApplied: false };
|
|
210
335
|
try {
|
|
211
336
|
let selection = model.select().where(where);
|
|
337
|
+
let orderApplied = options?.orderBy == null;
|
|
212
338
|
if (options?.orderBy && typeof selection.orderBy === 'function') {
|
|
213
339
|
selection = selection.orderBy(options.orderBy.field, options.orderBy.direction ?? 'DESC');
|
|
340
|
+
orderApplied = true;
|
|
341
|
+
}
|
|
342
|
+
const offset = options?.offset ?? 0;
|
|
343
|
+
const offsetSelection = orderApplied ? selection.offset : undefined;
|
|
344
|
+
const canApplyOffset = typeof offsetSelection === 'function';
|
|
345
|
+
const offsetApplied = offset === 0 || canApplyOffset;
|
|
346
|
+
if (offset > 0 && canApplyOffset) {
|
|
347
|
+
selection = offsetSelection.call(selection, offset);
|
|
214
348
|
}
|
|
215
|
-
if (options?.limit != null && typeof selection.limit === 'function') {
|
|
216
|
-
selection = selection.limit(options.limit);
|
|
349
|
+
if (options?.limit != null && orderApplied && typeof selection.limit === 'function') {
|
|
350
|
+
selection = selection.limit(options.limit + (offsetApplied ? 0 : offset));
|
|
217
351
|
}
|
|
218
352
|
const rows = await selection;
|
|
219
|
-
return { rows: Array.isArray(rows) ? rows : [], enabled: true };
|
|
353
|
+
return { rows: Array.isArray(rows) ? rows : [], enabled: true, offsetApplied };
|
|
220
354
|
}
|
|
221
355
|
catch {
|
|
222
356
|
// 表未创建 / 方言未启动等场景降级为空,不向上抛。
|
|
223
|
-
return { rows: [], enabled: false };
|
|
357
|
+
return { rows: [], enabled: false, offsetApplied: false };
|
|
224
358
|
}
|
|
225
359
|
}
|
|
226
360
|
async function listInbox(d, ctx, table, key, mapRow) {
|
|
@@ -228,29 +362,60 @@ async function listInbox(d, ctx, table, key, mapRow) {
|
|
|
228
362
|
const endpointKey = strField(d, '$endpoint', 'endpointKey', 'endpoint');
|
|
229
363
|
if (!adapter || !endpointKey)
|
|
230
364
|
return { error: '$adapter and $endpoint required' };
|
|
231
|
-
const limit =
|
|
232
|
-
const offset =
|
|
233
|
-
const
|
|
365
|
+
const limit = boundedIntegerField(d, 30, 1, 100, '$limit', 'limit');
|
|
366
|
+
const offset = boundedIntegerField(d, 0, 0, MAX_INBOX_OFFSET, '$offset', 'offset');
|
|
367
|
+
const unreadOnly = table === TABLE_NOTICE
|
|
368
|
+
&& boolField(d, false, '$unread_only', 'unreadOnly', 'unread_only');
|
|
369
|
+
const { rows, enabled, offsetApplied } = await readInboxRows(ctx, table, {
|
|
234
370
|
adapter,
|
|
235
371
|
endpoint_id: endpointKey,
|
|
372
|
+
...(unreadOnly ? { consumed: 0 } : {}),
|
|
236
373
|
}, {
|
|
237
374
|
// sort/limit 下推到 DB 侧(内存 sort/slice 保留,作为无下推能力模型的兜底)
|
|
238
375
|
orderBy: { field: 'created_at', direction: 'DESC' },
|
|
239
|
-
limit
|
|
376
|
+
limit,
|
|
377
|
+
offset,
|
|
240
378
|
});
|
|
241
379
|
const sorted = rows
|
|
242
380
|
.slice()
|
|
243
381
|
.sort((a, b) => Number(b.created_at ?? 0) - Number(a.created_at ?? 0))
|
|
244
|
-
.slice(offset, offset + limit)
|
|
382
|
+
.slice(offsetApplied ? 0 : offset, (offsetApplied ? 0 : offset) + limit)
|
|
245
383
|
.map(mapRow);
|
|
246
384
|
return { data: { [key]: sorted, inboxEnabled: enabled } };
|
|
247
385
|
}
|
|
248
|
-
/** request.list ——
|
|
386
|
+
/** request.list —— 优先 live EndpointManagement.listRequests;否则回退 unified_inbox_request。 */
|
|
249
387
|
async function listPendingRequests(d, ctx) {
|
|
250
388
|
const adapter = strField(d, '$adapter', 'adapter');
|
|
251
389
|
const endpointKey = strField(d, '$endpoint', 'endpointKey', 'endpoint');
|
|
252
390
|
if (!adapter || !endpointKey)
|
|
253
391
|
return { error: '$adapter and $endpoint required' };
|
|
392
|
+
if (ctx.withEndpointManagement) {
|
|
393
|
+
try {
|
|
394
|
+
const live = await ctx.withEndpointManagement(adapter, endpointKey, async (management) => {
|
|
395
|
+
if (!management.listRequests)
|
|
396
|
+
return undefined;
|
|
397
|
+
const pending = await management.listRequests();
|
|
398
|
+
const requests = pending.map((row) => mapRequestRow({
|
|
399
|
+
platform_request_id: row.platform_request_id,
|
|
400
|
+
type: row.type,
|
|
401
|
+
scene_type: row.scene_type ?? null,
|
|
402
|
+
scene_id: row.scene_id,
|
|
403
|
+
sub_type: row.sub_type ?? null,
|
|
404
|
+
actor_id: row.actor_id,
|
|
405
|
+
actor_name: row.actor_name ?? null,
|
|
406
|
+
comment: row.comment ?? null,
|
|
407
|
+
created_at: row.created_at,
|
|
408
|
+
resolved: 0,
|
|
409
|
+
}));
|
|
410
|
+
return { data: { requests, inboxEnabled: false, source: 'endpoint' } };
|
|
411
|
+
});
|
|
412
|
+
if (live)
|
|
413
|
+
return live;
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
return { error: errorMessage(error) };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
254
419
|
const { rows, enabled } = await readInboxRows(ctx, TABLE_REQUEST, {
|
|
255
420
|
adapter,
|
|
256
421
|
endpoint_id: endpointKey,
|
|
@@ -261,7 +426,7 @@ async function listPendingRequests(d, ctx) {
|
|
|
261
426
|
.filter((row) => Number(row.resolved ?? 0) === 0)
|
|
262
427
|
.sort((a, b) => Number(a.created_at ?? 0) - Number(b.created_at ?? 0))
|
|
263
428
|
.map(mapRequestRow);
|
|
264
|
-
return { data: { requests, inboxEnabled: enabled } };
|
|
429
|
+
return { data: { requests, inboxEnabled: enabled, source: 'inbox' } };
|
|
265
430
|
}
|
|
266
431
|
async function listInboxMessages(d, ctx) {
|
|
267
432
|
const adapter = strField(d, '$adapter', 'adapter');
|
|
@@ -271,7 +436,7 @@ async function listInboxMessages(d, ctx) {
|
|
|
271
436
|
if (!adapter || !endpointKey || !channelId || !channelType) {
|
|
272
437
|
return { error: '$adapter, $endpoint, $channel_id, $channel_type required' };
|
|
273
438
|
}
|
|
274
|
-
const limit =
|
|
439
|
+
const limit = boundedIntegerField(d, 50, 1, 100, '$limit', 'limit');
|
|
275
440
|
const beforeTs = optionalNum(d, '$before_ts', 'beforeTs', 'before_ts');
|
|
276
441
|
const beforeId = optionalNum(d, '$before_id', 'beforeId', 'before_id');
|
|
277
442
|
const parent = normalizeParent(d.$parent ?? d.parent);
|
|
@@ -338,16 +503,18 @@ function mapRequestRow(row) {
|
|
|
338
503
|
function mapNoticeRow(row) {
|
|
339
504
|
const actorId = row.actor_id ?? undefined;
|
|
340
505
|
const actorName = row.actor_name ?? undefined;
|
|
341
|
-
const sceneId = row.scene_id;
|
|
342
|
-
const sceneType = row.scene_type
|
|
506
|
+
const sceneId = String(row.scene_id ?? '');
|
|
507
|
+
const sceneType = row.scene_type == null ? undefined : String(row.scene_type);
|
|
343
508
|
return {
|
|
344
509
|
id: row.id,
|
|
345
510
|
platform_notice_id: row.platform_notice_id,
|
|
346
511
|
type: row.type,
|
|
512
|
+
noticeType: row.type,
|
|
347
513
|
scene_type: sceneType,
|
|
348
514
|
scene_id: sceneId,
|
|
349
515
|
channel_id: sceneId,
|
|
350
516
|
channel_type: sceneType,
|
|
517
|
+
channel: { id: sceneId, type: sceneType ?? '' },
|
|
351
518
|
sub_type: row.sub_type ?? undefined,
|
|
352
519
|
actor_id: actorId,
|
|
353
520
|
actor_name: actorName,
|
|
@@ -359,6 +526,8 @@ function mapNoticeRow(row) {
|
|
|
359
526
|
payload: row.payload,
|
|
360
527
|
created_at: row.created_at,
|
|
361
528
|
timestamp: row.created_at,
|
|
529
|
+
consumed: row.consumed,
|
|
530
|
+
consumed_at: row.consumed_at ?? undefined,
|
|
362
531
|
};
|
|
363
532
|
}
|
|
364
533
|
// ---------------------------------------------------------------- consumed 标记
|
|
@@ -414,6 +583,50 @@ async function actOnRequest(type, d, ctx) {
|
|
|
414
583
|
return { data: { success: true } };
|
|
415
584
|
});
|
|
416
585
|
}
|
|
586
|
+
function listLoginPending(ctx) {
|
|
587
|
+
const assist = ctx.loginAssist;
|
|
588
|
+
if (!assist)
|
|
589
|
+
return { error: '登录辅助未接线(LoginAssist 未挂载)' };
|
|
590
|
+
const tasks = assist.listPending().map((task) => ({
|
|
591
|
+
id: task.id,
|
|
592
|
+
adapter: task.adapter,
|
|
593
|
+
endpointKey: task.endpointKey,
|
|
594
|
+
type: task.type,
|
|
595
|
+
payload: task.payload,
|
|
596
|
+
createdAt: task.createdAt,
|
|
597
|
+
expiresAt: task.expiresAt,
|
|
598
|
+
}));
|
|
599
|
+
return { data: { tasks, count: tasks.length } };
|
|
600
|
+
}
|
|
601
|
+
function submitLogin(d, ctx) {
|
|
602
|
+
const assist = ctx.loginAssist;
|
|
603
|
+
if (!assist)
|
|
604
|
+
return { error: '登录辅助未接线(LoginAssist 未挂载)' };
|
|
605
|
+
const id = strField(d, '$id', 'id', 'taskId');
|
|
606
|
+
if (!id)
|
|
607
|
+
return { error: '$id required' };
|
|
608
|
+
const raw = d.$value ?? d.value ?? d.ticket ?? d.code ?? '';
|
|
609
|
+
const value = typeof raw === 'string' || (raw && typeof raw === 'object')
|
|
610
|
+
? raw
|
|
611
|
+
: String(raw ?? '');
|
|
612
|
+
const ok = assist.submit(id, value);
|
|
613
|
+
if (!ok)
|
|
614
|
+
return { error: `login task not found: ${id}` };
|
|
615
|
+
return { data: { success: true } };
|
|
616
|
+
}
|
|
617
|
+
function cancelLogin(d, ctx) {
|
|
618
|
+
const assist = ctx.loginAssist;
|
|
619
|
+
if (!assist)
|
|
620
|
+
return { error: '登录辅助未接线(LoginAssist 未挂载)' };
|
|
621
|
+
const id = strField(d, '$id', 'id', 'taskId');
|
|
622
|
+
if (!id)
|
|
623
|
+
return { error: '$id required' };
|
|
624
|
+
const reason = strField(d, '$reason', 'reason') || 'cancelled';
|
|
625
|
+
const ok = assist.cancel(id, reason);
|
|
626
|
+
if (!ok)
|
|
627
|
+
return { error: `login task not found: ${id}` };
|
|
628
|
+
return { data: { success: true } };
|
|
629
|
+
}
|
|
417
630
|
// ---------------------------------------------------------------- 社交读取
|
|
418
631
|
async function listFriends(d, ctx) {
|
|
419
632
|
const endpoint = requireEndpointAddress(d);
|
|
@@ -530,6 +743,20 @@ function numField(d, fallback, ...keys) {
|
|
|
530
743
|
}
|
|
531
744
|
return fallback;
|
|
532
745
|
}
|
|
746
|
+
function boundedIntegerField(d, fallback, minimum, maximum, ...keys) {
|
|
747
|
+
const value = numField(d, fallback, ...keys);
|
|
748
|
+
if (!Number.isFinite(value))
|
|
749
|
+
return fallback;
|
|
750
|
+
return Math.min(maximum, Math.max(minimum, Math.trunc(value)));
|
|
751
|
+
}
|
|
752
|
+
function boolField(d, fallback, ...keys) {
|
|
753
|
+
for (const key of keys) {
|
|
754
|
+
const value = d[key];
|
|
755
|
+
if (typeof value === 'boolean')
|
|
756
|
+
return value;
|
|
757
|
+
}
|
|
758
|
+
return fallback;
|
|
759
|
+
}
|
|
533
760
|
function optionalNum(d, ...keys) {
|
|
534
761
|
for (const key of keys) {
|
|
535
762
|
if (d[key] == null)
|
package/lib/console-rpc.d.ts
CHANGED
|
@@ -50,6 +50,16 @@ export type RuntimeConsoleRpcContext = {
|
|
|
50
50
|
setConfigKey?(pluginName: string, data: unknown): Promise<{
|
|
51
51
|
restartRequired: boolean;
|
|
52
52
|
}>;
|
|
53
|
+
/** Atomic, revision-checked runtime Workroom Catalog read/write. */
|
|
54
|
+
readWorkroomCatalog?(): Promise<Readonly<{
|
|
55
|
+
agents: Readonly<Record<string, unknown>>;
|
|
56
|
+
workrooms: Readonly<Record<string, unknown>>;
|
|
57
|
+
revision: string;
|
|
58
|
+
}>>;
|
|
59
|
+
setWorkroomCatalog?(workrooms: unknown, expectedRevision: string): Promise<Readonly<{
|
|
60
|
+
revision: string;
|
|
61
|
+
restartRequired: boolean;
|
|
62
|
+
}>>;
|
|
53
63
|
/** Project file manager (allowlisted paths under project root). */
|
|
54
64
|
listProjectFiles?(): Promise<readonly unknown[]>;
|
|
55
65
|
readProjectFile?(filePath: string): Promise<{
|
package/lib/console-rpc.js
CHANGED
|
@@ -116,6 +116,41 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
116
116
|
}
|
|
117
117
|
return payloads;
|
|
118
118
|
}
|
|
119
|
+
case 'workrooms:get': {
|
|
120
|
+
try {
|
|
121
|
+
if (!ctx.readWorkroomCatalog) {
|
|
122
|
+
emit({ requestId, error: 'Workroom Catalog read is not configured' });
|
|
123
|
+
return payloads;
|
|
124
|
+
}
|
|
125
|
+
emit({ requestId, data: await ctx.readWorkroomCatalog() });
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
emit({ requestId, error: error instanceof Error ? error.message : String(error) });
|
|
129
|
+
}
|
|
130
|
+
return payloads;
|
|
131
|
+
}
|
|
132
|
+
case 'workrooms:set': {
|
|
133
|
+
try {
|
|
134
|
+
if (!ctx.setWorkroomCatalog) {
|
|
135
|
+
emit({ requestId, error: 'Workroom Catalog write is not configured' });
|
|
136
|
+
return payloads;
|
|
137
|
+
}
|
|
138
|
+
const expectedRevision = typeof message.expectedRevision === 'string'
|
|
139
|
+
? message.expectedRevision
|
|
140
|
+
: '';
|
|
141
|
+
if (!/^[a-f0-9]{64}$/u.test(expectedRevision)) {
|
|
142
|
+
emit({ requestId, error: 'expectedRevision is required' });
|
|
143
|
+
return payloads;
|
|
144
|
+
}
|
|
145
|
+
const result = await ctx.setWorkroomCatalog(message.data, expectedRevision);
|
|
146
|
+
ctx.publishEvent?.('workrooms:updated', { revision: result.revision });
|
|
147
|
+
emit({ requestId, data: { success: true, ...result } });
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
emit({ requestId, error: error instanceof Error ? error.message : String(error) });
|
|
151
|
+
}
|
|
152
|
+
return payloads;
|
|
153
|
+
}
|
|
119
154
|
case 'config:save-yaml': {
|
|
120
155
|
try {
|
|
121
156
|
const yaml = message.yaml;
|
|
@@ -664,6 +699,10 @@ export function isDemoHttpAllowed(method, pathname, apiBase) {
|
|
|
664
699
|
const m = method.toUpperCase();
|
|
665
700
|
if (m === 'GET' && pathname === `${normalizedBase}/events`)
|
|
666
701
|
return true;
|
|
702
|
+
if (m === 'GET' && pathname === `${normalizedBase}/events/history`)
|
|
703
|
+
return true;
|
|
704
|
+
if (m === 'GET' && DEMO_INTROSPECTION_PATHS.has(pathname.slice(normalizedBase.length)))
|
|
705
|
+
return true;
|
|
667
706
|
if (m === 'POST' && pathname === `${normalizedBase}/console/request`)
|
|
668
707
|
return true;
|
|
669
708
|
// Read-only dashboard / plugin inspection routes (legacy host-api parity).
|
|
@@ -676,3 +715,12 @@ export function isDemoHttpAllowed(method, pathname, apiBase) {
|
|
|
676
715
|
return true;
|
|
677
716
|
return false;
|
|
678
717
|
}
|
|
718
|
+
const DEMO_INTROSPECTION_PATHS = new Set([
|
|
719
|
+
'/introspection/commands',
|
|
720
|
+
'/introspection/middlewares',
|
|
721
|
+
'/introspection/components',
|
|
722
|
+
'/introspection/endpoints',
|
|
723
|
+
'/introspection/bindings',
|
|
724
|
+
'/introspection/tools',
|
|
725
|
+
'/introspection/mcp',
|
|
726
|
+
]);
|
package/lib/http-host.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
2
|
import { WebSocket } from 'ws';
|
|
3
3
|
import { type GenerationAdmissionBindable } from '@zhin.js/plugin-runtime';
|
|
4
|
-
import { TokenRegistry, type AuthScope, type ScopedTokenConfig } from './token-registry.js';
|
|
4
|
+
import { TokenRegistry, type AuthenticatedTokenPrincipal, type AuthScope, type ScopedTokenConfig } from './token-registry.js';
|
|
5
5
|
import { type ListedRoute, type RouteMeta } from './openapi.js';
|
|
6
6
|
export declare const REMOTE_CONSOLE_ORIGIN = "https://console.zhin.dev";
|
|
7
7
|
export interface WsConnection {
|
|
@@ -34,7 +34,7 @@ export interface HttpHostOptions {
|
|
|
34
34
|
/** Extra HTTP path prefixes that skip auth even under `apiBase`. */
|
|
35
35
|
readonly authExemptPaths?: readonly string[];
|
|
36
36
|
}
|
|
37
|
-
export type HttpHandler = (request: IncomingMessage, response: ServerResponse, url: URL, authScope: AuthScope) => void | Promise<void>;
|
|
37
|
+
export type HttpHandler = (request: IncomingMessage, response: ServerResponse, url: URL, authScope: AuthScope, authenticatedPrincipal?: AuthenticatedTokenPrincipal) => void | Promise<void>;
|
|
38
38
|
export interface HttpRouteRegistration {
|
|
39
39
|
(): void;
|
|
40
40
|
}
|
package/lib/http-host.js
CHANGED
|
@@ -218,7 +218,7 @@ export function createHttpHost(options = {}) {
|
|
|
218
218
|
return;
|
|
219
219
|
}
|
|
220
220
|
if (!release) {
|
|
221
|
-
await route.handler(request, response, url, auth.scope);
|
|
221
|
+
await route.handler(request, response, url, auth.scope, auth.principal);
|
|
222
222
|
return;
|
|
223
223
|
}
|
|
224
224
|
let released = false;
|
|
@@ -232,7 +232,7 @@ export function createHttpHost(options = {}) {
|
|
|
232
232
|
};
|
|
233
233
|
response.once('finish', releaseResponse);
|
|
234
234
|
response.once('close', releaseResponse);
|
|
235
|
-
await route.handler(request, response, url, auth.scope);
|
|
235
|
+
await route.handler(request, response, url, auth.scope, auth.principal);
|
|
236
236
|
if (response.writableFinished || response.destroyed)
|
|
237
237
|
releaseResponse();
|
|
238
238
|
}
|
|
@@ -272,7 +272,8 @@ export function createHttpHost(options = {}) {
|
|
|
272
272
|
if (!isDemoHttpAllowed(method, pathname, apiBase))
|
|
273
273
|
return { ok: false };
|
|
274
274
|
}
|
|
275
|
-
|
|
275
|
+
const principal = tokenRegistry.resolvePrincipal(token);
|
|
276
|
+
return { ok: true, scope, ...(principal ? { principal } : {}) };
|
|
276
277
|
}
|
|
277
278
|
function listListedRoutes() {
|
|
278
279
|
return Object.freeze(httpRoutes
|
package/lib/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { createHttpHost, httpHostToken, REMOTE_CONSOLE_ORIGIN, type HttpHandler, type HttpHost, type ProcessHttpHost, type HttpHostAddress, type HttpHostOptions, type HttpRouteRegistration, type WsConnection, type WsHandle, } from './http-host.js';
|
|
2
|
-
export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, type AuthScope, type ScopedTokenConfig, type TokenRegistryConfig, } from './token-registry.js';
|
|
2
|
+
export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, type AuthenticatedTokenPrincipal, type AuthScope, type ScopedTokenConfig, type TokenRegistryConfig, } from './token-registry.js';
|
|
3
3
|
export { timingSafeEqualString } from './timing-safe-equal.js';
|
|
4
4
|
export { createConsoleEventHub, type ConsoleEventHub, } from './console-events.js';
|
|
5
5
|
export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, type BuildOpenApiOptions, type ListedRoute, type OpenApiParameter, type RouteMeta, } from './openapi.js';
|
|
@@ -7,4 +7,4 @@ export { HttpBodyError, readJsonBody, } from './json-body.js';
|
|
|
7
7
|
export { dispatchRuntimeConsoleRpc, isDemoHttpAllowed, pickRpcReply, type RuntimeConsolePage, type RuntimeConsoleRpcContext, type RuntimeConsoleRpcMessage, type RuntimeConsoleRpcReply, type RuntimeEndpointSendInput, type RuntimeEndpointSummary, } from './console-rpc.js';
|
|
8
8
|
export { buildProjectFileTree, FILE_MANAGER_ALLOWED, FILE_MANAGER_BLOCKED, isProjectPathAllowed, listEnvFiles, readProjectFile, saveProjectFile, type FileTreeNode, } from './project-files.js';
|
|
9
9
|
export { registerConsoleRestPages, type ConsoleAgentIntrospection, type ConsoleAgentRuntime, type ConsoleAgentSessionTree, type ConsoleRestCtx, type ConsoleRestPagesOptions, } from './console-rest-pages.js';
|
|
10
|
-
export { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx, type ConsoleScheduleEngine, type ExtendedRpcResult, } from './console-rpc-extended.js';
|
|
10
|
+
export { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx, type ConsoleScheduleEngine, type ConsoleWorkroomProfileControlPort, type ConsoleWorkroomKnowledgeControlPort, type ExtendedRpcResult, } from './console-rpc-extended.js';
|
package/lib/token-registry.d.ts
CHANGED
|
@@ -3,7 +3,13 @@ export type AuthScope = 'full' | 'demo';
|
|
|
3
3
|
export type ScopedTokenConfig = {
|
|
4
4
|
readonly token: string;
|
|
5
5
|
readonly scope: AuthScope;
|
|
6
|
+
/** Root-configured subject binding; never accepted from an HTTP payload. */
|
|
7
|
+
readonly principalId?: string;
|
|
6
8
|
};
|
|
9
|
+
export interface AuthenticatedTokenPrincipal {
|
|
10
|
+
readonly principalId: string;
|
|
11
|
+
readonly scope: AuthScope;
|
|
12
|
+
}
|
|
7
13
|
export type TokenRegistryConfig = {
|
|
8
14
|
readonly primaryToken?: string;
|
|
9
15
|
readonly scopedTokens?: readonly ScopedTokenConfig[];
|
|
@@ -12,6 +18,7 @@ export declare class TokenRegistry {
|
|
|
12
18
|
#private;
|
|
13
19
|
constructor(config?: TokenRegistryConfig);
|
|
14
20
|
resolve(token: string): AuthScope | null;
|
|
21
|
+
resolvePrincipal(token: string): AuthenticatedTokenPrincipal | null;
|
|
15
22
|
hasAnyToken(): boolean;
|
|
16
23
|
primaryTokenPrefixForLog(): string;
|
|
17
24
|
}
|
package/lib/token-registry.js
CHANGED
|
@@ -3,18 +3,39 @@ export class TokenRegistry {
|
|
|
3
3
|
#entries = new Map();
|
|
4
4
|
constructor(config = {}) {
|
|
5
5
|
if (config.primaryToken)
|
|
6
|
-
this.#entries.set(config.primaryToken, 'full');
|
|
7
|
-
for (const { token, scope } of config.scopedTokens ?? []) {
|
|
8
|
-
if (token)
|
|
9
|
-
|
|
6
|
+
this.#entries.set(config.primaryToken, Object.freeze({ scope: 'full' }));
|
|
7
|
+
for (const { token, scope, principalId } of config.scopedTokens ?? []) {
|
|
8
|
+
if (!token)
|
|
9
|
+
continue;
|
|
10
|
+
if (principalId !== undefined && (!principalId.trim() || principalId !== principalId.trim())) {
|
|
11
|
+
throw new Error('HTTP token principalId is invalid');
|
|
12
|
+
}
|
|
13
|
+
const candidate = Object.freeze({ scope, ...(principalId === undefined ? {} : { principalId }) });
|
|
14
|
+
const existing = this.#entries.get(token);
|
|
15
|
+
if (existing && (existing.scope !== candidate.scope || existing.principalId !== candidate.principalId)) {
|
|
16
|
+
throw new Error('HTTP token has conflicting authority bindings');
|
|
17
|
+
}
|
|
18
|
+
this.#entries.set(token, candidate);
|
|
10
19
|
}
|
|
11
20
|
}
|
|
12
21
|
resolve(token) {
|
|
13
22
|
if (!token)
|
|
14
23
|
return null;
|
|
15
|
-
for (const [known,
|
|
24
|
+
for (const [known, binding] of this.#entries) {
|
|
16
25
|
if (timingSafeEqualString(known, token))
|
|
17
|
-
return scope;
|
|
26
|
+
return binding.scope;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
resolvePrincipal(token) {
|
|
31
|
+
if (!token)
|
|
32
|
+
return null;
|
|
33
|
+
for (const [known, binding] of this.#entries) {
|
|
34
|
+
if (timingSafeEqualString(known, token)) {
|
|
35
|
+
return binding.principalId
|
|
36
|
+
? Object.freeze({ principalId: binding.principalId, scope: binding.scope })
|
|
37
|
+
: null;
|
|
38
|
+
}
|
|
18
39
|
}
|
|
19
40
|
return null;
|
|
20
41
|
}
|
|
@@ -22,8 +43,8 @@ export class TokenRegistry {
|
|
|
22
43
|
return this.#entries.size > 0;
|
|
23
44
|
}
|
|
24
45
|
primaryTokenPrefixForLog() {
|
|
25
|
-
for (const [tok,
|
|
26
|
-
if (scope === 'full')
|
|
46
|
+
for (const [tok, binding] of this.#entries) {
|
|
47
|
+
if (binding.scope === 'full')
|
|
27
48
|
return tok.slice(0, 6);
|
|
28
49
|
}
|
|
29
50
|
const first = this.#entries.keys().next().value;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/host-http",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
4
4
|
"description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"ws": "^8.21.1",
|
|
21
|
-
"@zhin.js/console-protocol": "1.1.
|
|
21
|
+
"@zhin.js/console-protocol": "1.1.4",
|
|
22
22
|
"@zhin.js/host-http-contract": "1.0.1",
|
|
23
23
|
"@zhin.js/logger": "1.0.76",
|
|
24
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
24
|
+
"@zhin.js/plugin-runtime": "1.1.7"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^26.1.2",
|