@hotmeshio/long-tail 0.24.0 → 0.24.2
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/build/adapters/express.d.ts +3 -1
- package/build/adapters/express.js +11 -1
- package/build/api/yaml-workflows/cron.d.ts +3 -2
- package/build/api/yaml-workflows/cron.js +6 -1
- package/build/api/yaml-workflows/deploy.d.ts +9 -4
- package/build/api/yaml-workflows/deploy.js +39 -12
- package/build/lib/db/schemas/039_nats_ws_tickets.sql +12 -0
- package/build/lib/db/schemas/040_oauth_client_consent.sql +15 -0
- package/build/lib/events/browser-view.d.ts +5 -0
- package/build/lib/events/browser-view.js +16 -0
- package/build/lib/events/nats-ws-filter.d.ts +8 -8
- package/build/lib/events/nats-ws-filter.js +49 -34
- package/build/lib/events/nats-ws-proxy.d.ts +5 -0
- package/build/lib/events/nats-ws-proxy.js +63 -18
- package/build/lib/events/nats-ws-ticket.d.ts +12 -3
- package/build/lib/events/nats-ws-ticket.js +25 -4
- package/build/modules/config.d.ts +9 -0
- package/build/modules/config.js +11 -0
- package/build/modules/maintenance.d.ts +2 -0
- package/build/modules/maintenance.js +7 -0
- package/build/routes/agents.js +4 -3
- package/build/routes/oauth-server/authorize.js +6 -4
- package/build/routes/yaml-workflows/cron.js +1 -1
- package/build/services/agent/authority.d.ts +1 -0
- package/build/services/agent/authority.js +7 -1
- package/build/services/agent/trigger-registry.js +7 -2
- package/build/services/auth/oauth-server/index.d.ts +1 -1
- package/build/services/auth/oauth-server/index.js +3 -1
- package/build/services/auth/oauth-server/sql.d.ts +21 -2
- package/build/services/auth/oauth-server/sql.js +41 -2
- package/build/services/auth/oauth-server/store.d.ts +22 -1
- package/build/services/auth/oauth-server/store.js +54 -5
- package/build/services/cron/index.js +5 -3
- package/build/services/maintenance/index.js +8 -0
- package/build/services/mcp/seed-service-account.d.ts +4 -1
- package/build/services/mcp/seed-service-account.js +11 -6
- package/build/services/yaml-workflow/invoke.js +7 -3
- package/build/services/yaml-workflow/workers/register.js +3 -13
- package/build/services/yaml-workflow/workers/step-tool.d.ts +7 -0
- package/build/services/yaml-workflow/workers/step-tool.js +69 -0
- package/build/system/mcp-servers/admin/agent-subscriptions.js +2 -1
- package/build/system/mcp-servers/admin/schemas.d.ts +6 -6
- package/build/system/mcp-servers/admin/yaml-workflows.js +6 -20
- package/build/system/mcp-servers/workflow.js +13 -25
- package/build/tsconfig.tsbuildinfo +1 -1
- package/build/types/maintenance.d.ts +2 -2
- package/docs/api/http/mcp-endpoint.md +3 -3
- package/docs/maintenance.md +51 -28
- package/docs/mcp.md +2 -0
- package/package.json +1 -1
|
@@ -68,7 +68,9 @@ export declare class LTExpressAdapter {
|
|
|
68
68
|
/**
|
|
69
69
|
* The OAuth well-known documents. Mount at the host's root, not under the
|
|
70
70
|
* base path: clients look for them at the origin. It answers only its two
|
|
71
|
-
* paths and passes everything else on.
|
|
71
|
+
* paths and passes everything else on. Warns when the issuer's path is not
|
|
72
|
+
* the base path: the router would then publish metadata for a server that
|
|
73
|
+
* is not the one mounted here, possibly over the host's own.
|
|
72
74
|
*/
|
|
73
75
|
getWellKnownRouter(): Router;
|
|
74
76
|
/** Whether a built dashboard was found to serve. */
|
|
@@ -15,6 +15,8 @@ const events_1 = require("../lib/events");
|
|
|
15
15
|
const socketio_1 = require("../lib/events/socketio");
|
|
16
16
|
const nats_1 = require("../lib/events/nats");
|
|
17
17
|
const nats_ws_proxy_1 = require("../lib/events/nats-ws-proxy");
|
|
18
|
+
const logger_1 = require("../lib/logger");
|
|
19
|
+
const oauth_server_1 = require("../modules/oauth-server");
|
|
18
20
|
/**
|
|
19
21
|
* Express adapter for mounting the Long Tail dashboard at an arbitrary subpath
|
|
20
22
|
* inside an existing Express-based application (NestJS, Fastify-Express, etc.).
|
|
@@ -153,9 +155,17 @@ class LTExpressAdapter {
|
|
|
153
155
|
/**
|
|
154
156
|
* The OAuth well-known documents. Mount at the host's root, not under the
|
|
155
157
|
* base path: clients look for them at the origin. It answers only its two
|
|
156
|
-
* paths and passes everything else on.
|
|
158
|
+
* paths and passes everything else on. Warns when the issuer's path is not
|
|
159
|
+
* the base path: the router would then publish metadata for a server that
|
|
160
|
+
* is not the one mounted here, possibly over the host's own.
|
|
157
161
|
*/
|
|
158
162
|
getWellKnownRouter() {
|
|
163
|
+
const settings = (0, oauth_server_1.getOAuthServerSettings)();
|
|
164
|
+
const issuerPath = settings ? new URL(settings.issuer).pathname.replace(/\/+$/, '') : null;
|
|
165
|
+
if (issuerPath !== null && issuerPath !== this.basePath) {
|
|
166
|
+
logger_1.loggerRegistry.warn(`[long-tail] OAuth issuer path "${issuerPath || '/'}" differs from the mount path "${this.basePath || '/'}"; `
|
|
167
|
+
+ `set the issuer to https://<host>${this.basePath}`);
|
|
168
|
+
}
|
|
159
169
|
return (0, oauth_well_known_1.createWellKnownRouter)();
|
|
160
170
|
}
|
|
161
171
|
/** Whether a built dashboard was found to serve. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { LTApiResult } from '../../types/sdk';
|
|
1
|
+
import type { LTApiAuth, LTApiResult } from '../../types/sdk';
|
|
2
2
|
/**
|
|
3
3
|
* Set or update the cron schedule for a YAML workflow.
|
|
4
4
|
*
|
|
@@ -9,6 +9,7 @@ import type { LTApiResult } from '../../types/sdk';
|
|
|
9
9
|
* @param input.cron_schedule — cron expression (e.g. "0 * * * *")
|
|
10
10
|
* @param input.cron_envelope — optional payload passed to each scheduled invocation
|
|
11
11
|
* @param input.execute_as — optional identity override for scheduled executions
|
|
12
|
+
* @param auth - the scheduling caller; the run identity must be able to call every built-in tool the workflow uses
|
|
12
13
|
* @returns `{ status: 200, data: YamlWorkflow }` the updated workflow record with cron fields set
|
|
13
14
|
*/
|
|
14
15
|
export declare function setCronSchedule(input: {
|
|
@@ -16,7 +17,7 @@ export declare function setCronSchedule(input: {
|
|
|
16
17
|
cron_schedule: string;
|
|
17
18
|
cron_envelope?: any;
|
|
18
19
|
execute_as?: string;
|
|
19
|
-
}): Promise<LTApiResult>;
|
|
20
|
+
}, auth?: LTApiAuth): Promise<LTApiResult>;
|
|
20
21
|
/**
|
|
21
22
|
* Remove the cron schedule from a YAML workflow.
|
|
22
23
|
*
|
|
@@ -38,6 +38,7 @@ exports.clearCronSchedule = clearCronSchedule;
|
|
|
38
38
|
exports.getCronStatus = getCronStatus;
|
|
39
39
|
const yamlDb = __importStar(require("../../services/yaml-workflow/db"));
|
|
40
40
|
const cron_1 = require("../../services/cron");
|
|
41
|
+
const deploy_1 = require("./deploy");
|
|
41
42
|
const helpers_1 = require("./helpers");
|
|
42
43
|
/**
|
|
43
44
|
* Set or update the cron schedule for a YAML workflow.
|
|
@@ -49,14 +50,18 @@ const helpers_1 = require("./helpers");
|
|
|
49
50
|
* @param input.cron_schedule — cron expression (e.g. "0 * * * *")
|
|
50
51
|
* @param input.cron_envelope — optional payload passed to each scheduled invocation
|
|
51
52
|
* @param input.execute_as — optional identity override for scheduled executions
|
|
53
|
+
* @param auth - the scheduling caller; the run identity must be able to call every built-in tool the workflow uses
|
|
52
54
|
* @returns `{ status: 200, data: YamlWorkflow }` the updated workflow record with cron fields set
|
|
53
55
|
*/
|
|
54
|
-
async function setCronSchedule(input) {
|
|
56
|
+
async function setCronSchedule(input, auth) {
|
|
55
57
|
try {
|
|
56
58
|
const wf = await yamlDb.getYamlWorkflow(input.id);
|
|
57
59
|
if (!wf) {
|
|
58
60
|
return { status: 404, error: 'YAML workflow not found' };
|
|
59
61
|
}
|
|
62
|
+
const refused = await (0, deploy_1.invokeRefusal)(wf, auth, input.execute_as);
|
|
63
|
+
if (refused)
|
|
64
|
+
return refused;
|
|
60
65
|
if (!input.cron_schedule || typeof input.cron_schedule !== 'string') {
|
|
61
66
|
return { status: 400, error: 'cron_schedule is required' };
|
|
62
67
|
}
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import type { LTYamlWorkflowRecord } from '../../types/yaml-workflow';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* without a caller (in-process), allowed.
|
|
3
|
+
* Deploying or activating a compiled workflow needs the caller to be able to
|
|
4
|
+
* call every built-in tool it uses: automation never does more than its
|
|
5
|
+
* author may. Null when allowed; without a caller (in-process), allowed.
|
|
7
6
|
*/
|
|
8
7
|
export declare function deployRefusal(wf: LTYamlWorkflowRecord, auth: LTApiAuth | undefined): Promise<LTApiResult | null>;
|
|
8
|
+
/**
|
|
9
|
+
* Invoking or scheduling a compiled workflow needs the identity it runs as
|
|
10
|
+
* (execute_as, else the caller) to be able to call every built-in tool it
|
|
11
|
+
* uses. Without a caller (in-process), allowed.
|
|
12
|
+
*/
|
|
13
|
+
export declare function invokeRefusal(wf: LTYamlWorkflowRecord, auth: LTApiAuth | undefined, executeAs?: string): Promise<LTApiResult | null>;
|
|
9
14
|
import type { LTApiResult, LTApiAuth } from '../../types/sdk';
|
|
10
15
|
/**
|
|
11
16
|
* Deploy a YAML workflow and all sibling workflows sharing its app_id namespace.
|
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.deployRefusal = deployRefusal;
|
|
37
|
+
exports.invokeRefusal = invokeRefusal;
|
|
37
38
|
exports.deployYamlWorkflow = deployYamlWorkflow;
|
|
38
39
|
exports.activateYamlWorkflow = activateYamlWorkflow;
|
|
39
40
|
exports.invokeYamlWorkflow = invokeYamlWorkflow;
|
|
@@ -45,21 +46,17 @@ const yamlWorkers = __importStar(require("../../services/yaml-workflow/workers")
|
|
|
45
46
|
const invoke_1 = require("../../services/yaml-workflow/invoke");
|
|
46
47
|
const workflow_invocation_1 = require("../../services/workflow-invocation");
|
|
47
48
|
const capabilities_1 = require("../../modules/capabilities");
|
|
49
|
+
const userService = __importStar(require("../../services/user"));
|
|
50
|
+
const uuid_1 = require("../../lib/uuid");
|
|
48
51
|
const tools_1 = require("../mcp/tools");
|
|
49
52
|
/**
|
|
50
|
-
* A
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* without a caller (in-process), allowed.
|
|
53
|
+
* A refusal when the person cannot call every built-in tool these workflows'
|
|
54
|
+
* steps use; null when they can. Steps run as the invoker under each tool's
|
|
55
|
+
* gate, so this answers up front what the run would refuse step by step.
|
|
54
56
|
*/
|
|
55
|
-
async function
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const access = (0, capabilities_1.capabilityAccess)({ userId: auth.userId });
|
|
59
|
-
// A deploy publishes every workflow sharing the app id, so every one is checked.
|
|
60
|
-
const siblings = await yamlDb.listYamlWorkflowsByAppId(wf.app_id);
|
|
61
|
-
const steps = [wf, ...siblings.filter((w) => w.id !== wf.id)].flatMap((w) => w.activity_manifest ?? []);
|
|
62
|
-
for (const step of steps) {
|
|
57
|
+
async function toolStepRefusal(workflows, userId) {
|
|
58
|
+
const access = (0, capabilities_1.capabilityAccess)({ userId });
|
|
59
|
+
for (const step of workflows.flatMap((w) => w.activity_manifest ?? [])) {
|
|
63
60
|
if (step.tool_source !== 'mcp' || !step.mcp_server_id || !step.mcp_tool_name)
|
|
64
61
|
continue;
|
|
65
62
|
const builtin = await (0, tools_1.builtinServerFor)(step.mcp_server_id);
|
|
@@ -69,6 +66,33 @@ async function deployRefusal(wf, auth) {
|
|
|
69
66
|
}
|
|
70
67
|
return null;
|
|
71
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Deploying or activating a compiled workflow needs the caller to be able to
|
|
71
|
+
* call every built-in tool it uses: automation never does more than its
|
|
72
|
+
* author may. Null when allowed; without a caller (in-process), allowed.
|
|
73
|
+
*/
|
|
74
|
+
async function deployRefusal(wf, auth) {
|
|
75
|
+
if (!auth?.userId)
|
|
76
|
+
return null;
|
|
77
|
+
// A deploy publishes every workflow sharing the app id, so every one is checked.
|
|
78
|
+
const siblings = await yamlDb.listYamlWorkflowsByAppId(wf.app_id);
|
|
79
|
+
return toolStepRefusal([wf, ...siblings.filter((w) => w.id !== wf.id)], auth.userId);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Invoking or scheduling a compiled workflow needs the identity it runs as
|
|
83
|
+
* (execute_as, else the caller) to be able to call every built-in tool it
|
|
84
|
+
* uses. Without a caller (in-process), allowed.
|
|
85
|
+
*/
|
|
86
|
+
async function invokeRefusal(wf, auth, executeAs) {
|
|
87
|
+
if (!auth?.userId)
|
|
88
|
+
return null;
|
|
89
|
+
if (!executeAs)
|
|
90
|
+
return toolStepRefusal([wf], auth.userId);
|
|
91
|
+
const runAs = (0, uuid_1.isUuid)(executeAs) ? await userService.getUser(executeAs) : await userService.getUserByExternalId(executeAs);
|
|
92
|
+
if (!runAs)
|
|
93
|
+
return { status: 404, error: `execute_as principal "${executeAs}" not found` };
|
|
94
|
+
return toolStepRefusal([wf], runAs.id);
|
|
95
|
+
}
|
|
72
96
|
const helpers_1 = require("./helpers");
|
|
73
97
|
/**
|
|
74
98
|
* Deploy a YAML workflow and all sibling workflows sharing its app_id namespace.
|
|
@@ -189,6 +213,9 @@ async function invokeYamlWorkflow(input, auth) {
|
|
|
189
213
|
return { status: 403, error: 'execute_as requires an authenticated caller' };
|
|
190
214
|
await (0, workflow_invocation_1.assertMayActAs)(auth.userId, input.execute_as);
|
|
191
215
|
}
|
|
216
|
+
const refused = await invokeRefusal(wf, auth, input.execute_as);
|
|
217
|
+
if (refused)
|
|
218
|
+
return refused;
|
|
192
219
|
const result = await (0, invoke_1.invokeYamlWorkflow)(wf, {
|
|
193
220
|
data: input.data,
|
|
194
221
|
sync: input.sync,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Migration 039: single-use NATS WebSocket proxy tickets
|
|
2
|
+
--
|
|
3
|
+
-- A ticket admits one upgrade. The proxy records its id here when it is
|
|
4
|
+
-- spent; a second upgrade with the same ticket finds the row and is refused.
|
|
5
|
+
-- Rows are kept until the ticket expires.
|
|
6
|
+
|
|
7
|
+
CREATE TABLE IF NOT EXISTS lt_nats_ws_tickets (
|
|
8
|
+
jti TEXT PRIMARY KEY,
|
|
9
|
+
expires_at TIMESTAMPTZ NOT NULL
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
CREATE INDEX IF NOT EXISTS idx_lt_nats_ws_tickets_expires ON lt_nats_ws_tickets (expires_at);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- Migration 040: when a person last consented to a grant for an OAuth client
|
|
2
|
+
--
|
|
3
|
+
-- Registration needs no sign-in, so a client that never reaches consent is
|
|
4
|
+
-- deleted after the retention window. A client someone has consented to is
|
|
5
|
+
-- kept, so the app can always reconnect with the client id it holds.
|
|
6
|
+
|
|
7
|
+
ALTER TABLE lt_oauth_clients ADD COLUMN IF NOT EXISTS last_consent_at TIMESTAMPTZ;
|
|
8
|
+
|
|
9
|
+
-- Clients consented to before this migration: their newest grant.
|
|
10
|
+
UPDATE lt_oauth_clients c
|
|
11
|
+
SET last_consent_at = g.created_at
|
|
12
|
+
FROM (
|
|
13
|
+
SELECT client_id, MAX(created_at) AS created_at FROM lt_oauth_grants GROUP BY client_id
|
|
14
|
+
) g
|
|
15
|
+
WHERE c.client_id = g.client_id AND c.last_consent_at IS NULL;
|
|
@@ -13,3 +13,8 @@ export declare function eventForViewer(event: LTEvent, viewer: BrowserViewer): L
|
|
|
13
13
|
export declare function payloadForViewer(payload: Buffer, viewer: BrowserViewer): Buffer | null;
|
|
14
14
|
/** Whether a person is a builder, looked up lazily to keep lib free of an import cycle. */
|
|
15
15
|
export declare function viewerFor(userId: string | null | undefined): Promise<BrowserViewer>;
|
|
16
|
+
/**
|
|
17
|
+
* How a connection holder sees events, or null when the account is gone or
|
|
18
|
+
* not active. Looked up lazily to keep lib free of an import cycle.
|
|
19
|
+
*/
|
|
20
|
+
export declare function holderView(userId: string): Promise<BrowserViewer | null>;
|
|
@@ -37,6 +37,7 @@ exports.BROWSER_ESCALATION_FIELDS = void 0;
|
|
|
37
37
|
exports.eventForViewer = eventForViewer;
|
|
38
38
|
exports.payloadForViewer = payloadForViewer;
|
|
39
39
|
exports.viewerFor = viewerFor;
|
|
40
|
+
exports.holderView = holderView;
|
|
40
41
|
/**
|
|
41
42
|
* What a browser receives of an event. Builders receive events whole. Anyone
|
|
42
43
|
* else receives enough to route and refresh: escalation events keep their
|
|
@@ -47,6 +48,7 @@ exports.viewerFor = viewerFor;
|
|
|
47
48
|
* applies only where events leave for a browser.
|
|
48
49
|
*/
|
|
49
50
|
const ESCALATION_SUBJECT = 'system.escalation.';
|
|
51
|
+
const ACTIVE_ACCOUNT = 'active';
|
|
50
52
|
/** Escalation event fields a browser needs to route a refresh and follow a claim. */
|
|
51
53
|
exports.BROWSER_ESCALATION_FIELDS = [
|
|
52
54
|
'id', 'type', 'subtype', 'status', 'priority', 'role',
|
|
@@ -99,3 +101,17 @@ async function viewerFor(userId) {
|
|
|
99
101
|
return { builder: false };
|
|
100
102
|
}
|
|
101
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* How a connection holder sees events, or null when the account is gone or
|
|
106
|
+
* not active. Looked up lazily to keep lib free of an import cycle.
|
|
107
|
+
*/
|
|
108
|
+
async function holderView(userId) {
|
|
109
|
+
const [{ getUser, getUserByExternalId }, { isUuid }] = await Promise.all([
|
|
110
|
+
Promise.resolve().then(() => __importStar(require('../../services/user'))),
|
|
111
|
+
Promise.resolve().then(() => __importStar(require('../uuid'))),
|
|
112
|
+
]);
|
|
113
|
+
const user = isUuid(userId) ? await getUser(userId) : await getUserByExternalId(userId);
|
|
114
|
+
if (!user || user.status !== ACTIVE_ACCOUNT)
|
|
115
|
+
return null;
|
|
116
|
+
return viewerFor(user.id);
|
|
117
|
+
}
|
|
@@ -5,25 +5,25 @@
|
|
|
5
5
|
*
|
|
6
6
|
* - CONNECT is rewritten to carry the server's credential (the browser is
|
|
7
7
|
* never given it), keeping the client's other options.
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
8
|
+
* - SUB, UNSUB, PING and PONG are forwarded, rebuilt from their parsed fields.
|
|
9
|
+
* - Any other operation, PUB and HPUB included, marks the stream malformed
|
|
10
|
+
* and the proxy closes the connection.
|
|
10
11
|
*
|
|
11
|
-
* The
|
|
12
|
-
*
|
|
13
|
-
* frames, so the filter keeps whatever it has not yet seen whole.
|
|
12
|
+
* The filter reads lines as nats-server does: fields split on spaces or tabs,
|
|
13
|
+
* and a line ends at LF with or without a preceding CR. Operations can span
|
|
14
|
+
* WebSocket frames, so the filter keeps whatever it has not yet seen whole.
|
|
14
15
|
*/
|
|
15
16
|
export declare class NatsClientFilter {
|
|
16
17
|
private readonly authToken;
|
|
17
18
|
private pending;
|
|
18
|
-
/** Payload bytes (plus the trailing CRLF) of a dropped publish still to skip. */
|
|
19
|
-
private skip;
|
|
20
19
|
private failed;
|
|
21
20
|
constructor(authToken: string | null);
|
|
22
21
|
/** Whether the stream was malformed; the proxy closes the connection. */
|
|
23
22
|
get malformed(): boolean;
|
|
24
23
|
/** The bytes to forward upstream for one chunk from the browser. */
|
|
25
24
|
feed(chunk: Buffer): Buffer;
|
|
26
|
-
|
|
25
|
+
private fail;
|
|
26
|
+
/** The line to forward, or null to forward nothing. */
|
|
27
27
|
private filterLine;
|
|
28
28
|
private rewriteConnect;
|
|
29
29
|
}
|
|
@@ -6,23 +6,34 @@
|
|
|
6
6
|
*
|
|
7
7
|
* - CONNECT is rewritten to carry the server's credential (the browser is
|
|
8
8
|
* never given it), keeping the client's other options.
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
9
|
+
* - SUB, UNSUB, PING and PONG are forwarded, rebuilt from their parsed fields.
|
|
10
|
+
* - Any other operation, PUB and HPUB included, marks the stream malformed
|
|
11
|
+
* and the proxy closes the connection.
|
|
11
12
|
*
|
|
12
|
-
* The
|
|
13
|
-
*
|
|
14
|
-
* frames, so the filter keeps whatever it has not yet seen whole.
|
|
13
|
+
* The filter reads lines as nats-server does: fields split on spaces or tabs,
|
|
14
|
+
* and a line ends at LF with or without a preceding CR. Operations can span
|
|
15
|
+
* WebSocket frames, so the filter keeps whatever it has not yet seen whole.
|
|
15
16
|
*/
|
|
16
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
18
|
exports.NatsClientFilter = void 0;
|
|
18
|
-
const
|
|
19
|
+
const LF = 0x0a;
|
|
19
20
|
const MAX_LINE = 64 * 1024;
|
|
21
|
+
const FIELD_DELIMITER = /[ \t]+/;
|
|
22
|
+
/** The operation name and its argument text. */
|
|
23
|
+
const OPERATION = /^[ \t]*([^ \t]+)[ \t]*(.*)$/s;
|
|
24
|
+
/** Control characters other than tab never appear inside a client line. */
|
|
25
|
+
const CONTROL_CHARACTER = /[\x00-\x08\x0a-\x1f\x7f]/;
|
|
26
|
+
/** Client operations forwarded, with the number of fields each takes. */
|
|
27
|
+
const ALLOWED_OPS = {
|
|
28
|
+
SUB: { min: 2, max: 3 },
|
|
29
|
+
UNSUB: { min: 1, max: 2 },
|
|
30
|
+
PING: { min: 0, max: 0 },
|
|
31
|
+
PONG: { min: 0, max: 0 },
|
|
32
|
+
};
|
|
20
33
|
class NatsClientFilter {
|
|
21
34
|
constructor(authToken) {
|
|
22
35
|
this.authToken = authToken;
|
|
23
36
|
this.pending = Buffer.alloc(0);
|
|
24
|
-
/** Payload bytes (plus the trailing CRLF) of a dropped publish still to skip. */
|
|
25
|
-
this.skip = 0;
|
|
26
37
|
this.failed = false;
|
|
27
38
|
}
|
|
28
39
|
/** Whether the stream was malformed; the proxy closes the connection. */
|
|
@@ -36,22 +47,15 @@ class NatsClientFilter {
|
|
|
36
47
|
this.pending = this.pending.length ? Buffer.concat([this.pending, chunk]) : chunk;
|
|
37
48
|
const out = [];
|
|
38
49
|
for (;;) {
|
|
39
|
-
|
|
40
|
-
const n = Math.min(this.skip, this.pending.length);
|
|
41
|
-
this.pending = this.pending.subarray(n);
|
|
42
|
-
this.skip -= n;
|
|
43
|
-
if (this.skip > 0)
|
|
44
|
-
break;
|
|
45
|
-
}
|
|
46
|
-
const end = this.pending.indexOf(CRLF);
|
|
50
|
+
const end = this.pending.indexOf(LF);
|
|
47
51
|
if (end === -1) {
|
|
48
52
|
if (this.pending.length > MAX_LINE)
|
|
49
|
-
this.
|
|
53
|
+
return this.fail();
|
|
50
54
|
break;
|
|
51
55
|
}
|
|
52
|
-
const
|
|
53
|
-
this.pending = this.pending.subarray(end +
|
|
54
|
-
const forwarded = this.filterLine(
|
|
56
|
+
const raw = this.pending.subarray(0, end > 0 && this.pending[end - 1] === 0x0d ? end - 1 : end);
|
|
57
|
+
this.pending = this.pending.subarray(end + 1);
|
|
58
|
+
const forwarded = this.filterLine(raw.toString('utf8'));
|
|
55
59
|
if (this.failed)
|
|
56
60
|
return Buffer.alloc(0);
|
|
57
61
|
if (forwarded !== null)
|
|
@@ -59,24 +63,31 @@ class NatsClientFilter {
|
|
|
59
63
|
}
|
|
60
64
|
return Buffer.concat(out);
|
|
61
65
|
}
|
|
62
|
-
|
|
66
|
+
fail() {
|
|
67
|
+
this.failed = true;
|
|
68
|
+
this.pending = Buffer.alloc(0);
|
|
69
|
+
return Buffer.alloc(0);
|
|
70
|
+
}
|
|
71
|
+
/** The line to forward, or null to forward nothing. */
|
|
63
72
|
filterLine(line) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
if (CONTROL_CHARACTER.test(line)) {
|
|
74
|
+
this.failed = true;
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const match = OPERATION.exec(line);
|
|
78
|
+
if (!match)
|
|
79
|
+
return null;
|
|
80
|
+
const op = match[1].toUpperCase();
|
|
81
|
+
const rest = match[2];
|
|
67
82
|
if (op === 'CONNECT')
|
|
68
|
-
return this.rewriteConnect(
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
this.failed = true;
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
this.skip = size + CRLF.length;
|
|
83
|
+
return this.rewriteConnect(rest);
|
|
84
|
+
const shape = ALLOWED_OPS[op];
|
|
85
|
+
const fields = rest === '' ? [] : rest.split(FIELD_DELIMITER).filter(Boolean);
|
|
86
|
+
if (!shape || fields.length < shape.min || fields.length > shape.max) {
|
|
87
|
+
this.failed = true;
|
|
77
88
|
return null;
|
|
78
89
|
}
|
|
79
|
-
return
|
|
90
|
+
return [op, ...fields].join(' ');
|
|
80
91
|
}
|
|
81
92
|
rewriteConnect(json) {
|
|
82
93
|
let options;
|
|
@@ -87,6 +98,10 @@ class NatsClientFilter {
|
|
|
87
98
|
this.failed = true;
|
|
88
99
|
return null;
|
|
89
100
|
}
|
|
101
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
102
|
+
this.failed = true;
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
90
105
|
const { auth_token: _token, user: _user, pass: _pass, jwt: _jwt, nkey: _nkey, sig: _sig, ...rest } = options;
|
|
91
106
|
return `CONNECT ${JSON.stringify(this.authToken ? { ...rest, auth_token: this.authToken } : rest)}`;
|
|
92
107
|
}
|
|
@@ -18,6 +18,11 @@ export declare function deriveWsUrlFromRequest(req: {
|
|
|
18
18
|
* through an ALB or reverse proxy). This handler upgrades the connection
|
|
19
19
|
* and bridges bidirectionally to the internal NATS WS target.
|
|
20
20
|
*
|
|
21
|
+
* A connection needs an unspent ticket for an active account. The holder's
|
|
22
|
+
* account is rechecked every NATS_WS_RECHECK_SECONDS; a deactivation or a
|
|
23
|
+
* change in builder access closes the connection, and the dashboard
|
|
24
|
+
* reconnects with fresh credentials.
|
|
25
|
+
*
|
|
21
26
|
* The proxy auto-derives the public `wsUrl` from the first incoming
|
|
22
27
|
* request's headers, respecting `X-Forwarded-Proto` and `X-Forwarded-Host`
|
|
23
28
|
* so the correct `wss://` scheme is used behind TLS-terminating load balancers.
|
|
@@ -7,6 +7,7 @@ const ws_1 = require("ws");
|
|
|
7
7
|
const logger_1 = require("../logger");
|
|
8
8
|
const nats_ws_filter_1 = require("./nats-ws-filter");
|
|
9
9
|
const nats_ws_server_filter_1 = require("./nats-ws-server-filter");
|
|
10
|
+
const config_1 = require("../../modules/config");
|
|
10
11
|
const browser_view_1 = require("./browser-view");
|
|
11
12
|
const nats_ws_ticket_1 = require("./nats-ws-ticket");
|
|
12
13
|
/** Default path for the NATS WebSocket proxy endpoint. */
|
|
@@ -23,6 +24,31 @@ function deriveWsUrlFromRequest(req, basePath = '') {
|
|
|
23
24
|
const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost';
|
|
24
25
|
return `${scheme}://${host}${basePath}${exports.NATS_WS_PROXY_PATH}`;
|
|
25
26
|
}
|
|
27
|
+
/** The path and ticket of an upgrade's request target; null when it does not parse. */
|
|
28
|
+
function upgradeTarget(requestTarget) {
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(requestTarget ?? '/', 'http://proxy');
|
|
31
|
+
return { pathname: url.pathname, ticket: url.searchParams.get(nats_ws_ticket_1.NATS_WS_TICKET_PARAM) };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function refuseUpgrade(socket) {
|
|
38
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
39
|
+
socket.destroy();
|
|
40
|
+
}
|
|
41
|
+
/** Whether the holder's account still stands as it did when the connection opened. */
|
|
42
|
+
async function holderUnchanged(userId, viewer) {
|
|
43
|
+
try {
|
|
44
|
+
const now = await (0, browser_view_1.holderView)(userId);
|
|
45
|
+
return !!now && now.builder === viewer.builder;
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
logger_1.loggerRegistry.warn(`[lt-nats-ws-proxy] holder recheck failed: ${err?.message}`);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
26
52
|
/**
|
|
27
53
|
* Attach a WebSocket proxy to an HTTP server that bridges browser
|
|
28
54
|
* connections to an internal NATS WebSocket endpoint.
|
|
@@ -31,6 +57,11 @@ function deriveWsUrlFromRequest(req, basePath = '') {
|
|
|
31
57
|
* through an ALB or reverse proxy). This handler upgrades the connection
|
|
32
58
|
* and bridges bidirectionally to the internal NATS WS target.
|
|
33
59
|
*
|
|
60
|
+
* A connection needs an unspent ticket for an active account. The holder's
|
|
61
|
+
* account is rechecked every NATS_WS_RECHECK_SECONDS; a deactivation or a
|
|
62
|
+
* change in builder access closes the connection, and the dashboard
|
|
63
|
+
* reconnects with fresh credentials.
|
|
64
|
+
*
|
|
34
65
|
* The proxy auto-derives the public `wsUrl` from the first incoming
|
|
35
66
|
* request's headers, respecting `X-Forwarded-Proto` and `X-Forwarded-Host`
|
|
36
67
|
* so the correct `wss://` scheme is used behind TLS-terminating load balancers.
|
|
@@ -38,22 +69,18 @@ function deriveWsUrlFromRequest(req, basePath = '') {
|
|
|
38
69
|
function attachNatsWsProxy(server, target, options = {}) {
|
|
39
70
|
const basePath = options.basePath || '';
|
|
40
71
|
const proxyPath = `${basePath}${exports.NATS_WS_PROXY_PATH}`;
|
|
41
|
-
const wss = new ws_1.WebSocketServer({ noServer: true });
|
|
72
|
+
const wss = new ws_1.WebSocketServer({ noServer: true, maxPayload: config_1.config.NATS_WS_MAX_PAYLOAD_BYTES });
|
|
42
73
|
let derived = false;
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
// Decided once per connection: a builder receives events whole, anyone
|
|
55
|
-
// else receives the browser view (lib/events/browser-view.ts).
|
|
56
|
-
const viewer = await (0, browser_view_1.viewerFor)(userId);
|
|
74
|
+
const admit = async (req, socket, head, ticketParam) => {
|
|
75
|
+
// Only a signed-in person, holding an unspent ticket the credentials route issued.
|
|
76
|
+
const ticket = (0, nats_ws_ticket_1.verifyNatsWsTicket)(ticketParam);
|
|
77
|
+
if (!ticket || !(await (0, nats_ws_ticket_1.spendNatsWsTicket)(ticket)))
|
|
78
|
+
return refuseUpgrade(socket);
|
|
79
|
+
// Decided per connection: a builder receives events whole, anyone else
|
|
80
|
+
// receives the browser view (lib/events/browser-view.ts).
|
|
81
|
+
const viewer = await (0, browser_view_1.holderView)(ticket.userId);
|
|
82
|
+
if (!viewer)
|
|
83
|
+
return refuseUpgrade(socket);
|
|
57
84
|
if (socket.destroyed)
|
|
58
85
|
return;
|
|
59
86
|
// Derive the public wsUrl from the first request's headers
|
|
@@ -63,7 +90,7 @@ function attachNatsWsProxy(server, target, options = {}) {
|
|
|
63
90
|
}
|
|
64
91
|
wss.handleUpgrade(req, socket, head, (clientWs) => {
|
|
65
92
|
const upstream = new ws_1.WebSocket(target);
|
|
66
|
-
// The browser reads; the proxy holds the credential and
|
|
93
|
+
// The browser reads; the proxy holds the credential and refuses its publishes.
|
|
67
94
|
const filter = new nats_ws_filter_1.NatsClientFilter(options.authToken ?? null);
|
|
68
95
|
const inbound = viewer.builder ? null : new nats_ws_server_filter_1.NatsServerFilter((payload) => (0, browser_view_1.payloadForViewer)(payload, viewer));
|
|
69
96
|
upstream.on('open', () => {
|
|
@@ -71,7 +98,7 @@ function attachNatsWsProxy(server, target, options = {}) {
|
|
|
71
98
|
const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
72
99
|
const forward = filter.feed(chunk);
|
|
73
100
|
if (filter.malformed) {
|
|
74
|
-
|
|
101
|
+
close();
|
|
75
102
|
return;
|
|
76
103
|
}
|
|
77
104
|
if (forward.length && upstream.readyState === ws_1.WebSocket.OPEN)
|
|
@@ -90,10 +117,18 @@ function attachNatsWsProxy(server, target, options = {}) {
|
|
|
90
117
|
clientWs.send(forward);
|
|
91
118
|
});
|
|
92
119
|
});
|
|
120
|
+
const recheck = setInterval(() => {
|
|
121
|
+
holderUnchanged(ticket.userId, viewer).then((unchanged) => {
|
|
122
|
+
if (!unchanged)
|
|
123
|
+
close();
|
|
124
|
+
});
|
|
125
|
+
}, config_1.config.NATS_WS_RECHECK_SECONDS * 1000);
|
|
126
|
+
recheck.unref();
|
|
93
127
|
const close = () => {
|
|
128
|
+
clearInterval(recheck);
|
|
94
129
|
if (clientWs.readyState === ws_1.WebSocket.OPEN)
|
|
95
130
|
clientWs.close();
|
|
96
|
-
if (upstream.readyState === ws_1.WebSocket.OPEN)
|
|
131
|
+
if (upstream.readyState === ws_1.WebSocket.OPEN || upstream.readyState === ws_1.WebSocket.CONNECTING)
|
|
97
132
|
upstream.close();
|
|
98
133
|
};
|
|
99
134
|
clientWs.on('close', close);
|
|
@@ -107,6 +142,16 @@ function attachNatsWsProxy(server, target, options = {}) {
|
|
|
107
142
|
close();
|
|
108
143
|
});
|
|
109
144
|
});
|
|
145
|
+
};
|
|
146
|
+
server.on('upgrade', (req, socket, head) => {
|
|
147
|
+
// A target that does not parse is not this proxy's; other listeners may take it.
|
|
148
|
+
const upgrade = upgradeTarget(req.url);
|
|
149
|
+
if (!upgrade || upgrade.pathname !== proxyPath)
|
|
150
|
+
return;
|
|
151
|
+
admit(req, socket, head, upgrade.ticket).catch((err) => {
|
|
152
|
+
logger_1.loggerRegistry.error(`[lt-nats-ws-proxy] upgrade failed: ${err?.message}`);
|
|
153
|
+
socket.destroy();
|
|
154
|
+
});
|
|
110
155
|
});
|
|
111
156
|
logger_1.loggerRegistry.info(`[lt-nats-ws-proxy] active: ${proxyPath} -> ${target}`);
|
|
112
157
|
}
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A browser's pass to the NATS WebSocket proxy, in place of the NATS server
|
|
3
3
|
* credential. It names the person in `sub` and carries no `userId`, so it
|
|
4
|
-
* authenticates nothing but the proxy upgrade.
|
|
4
|
+
* authenticates nothing but the proxy upgrade. Each ticket admits one
|
|
5
|
+
* upgrade: the dashboard fetches a fresh one for every connection attempt.
|
|
5
6
|
*/
|
|
6
7
|
export declare const NATS_WS_TICKET_TYPE = "nats-ws+jwt";
|
|
7
8
|
export declare const NATS_WS_TICKET_PARAM = "ticket";
|
|
9
|
+
export interface NatsWsTicket {
|
|
10
|
+
userId: string;
|
|
11
|
+
jti: string;
|
|
12
|
+
/** Expiry, in seconds since the epoch. */
|
|
13
|
+
exp: number;
|
|
14
|
+
}
|
|
8
15
|
export declare function signNatsWsTicket(userId: string): string | null;
|
|
9
|
-
/** The
|
|
10
|
-
export declare function verifyNatsWsTicket(ticket: string | null | undefined):
|
|
16
|
+
/** The claims of a valid ticket; null for anything else. Does not spend it. */
|
|
17
|
+
export declare function verifyNatsWsTicket(ticket: string | null | undefined): NatsWsTicket | null;
|
|
18
|
+
/** Spend a verified ticket. False when it was already spent. */
|
|
19
|
+
export declare function spendNatsWsTicket(ticket: NatsWsTicket): Promise<boolean>;
|