@hotmeshio/long-tail 0.24.0 → 0.24.1

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.
Files changed (48) hide show
  1. package/build/adapters/express.d.ts +3 -1
  2. package/build/adapters/express.js +11 -1
  3. package/build/api/yaml-workflows/cron.d.ts +3 -2
  4. package/build/api/yaml-workflows/cron.js +6 -1
  5. package/build/api/yaml-workflows/deploy.d.ts +9 -4
  6. package/build/api/yaml-workflows/deploy.js +39 -12
  7. package/build/lib/db/schemas/039_nats_ws_tickets.sql +12 -0
  8. package/build/lib/events/browser-view.d.ts +5 -0
  9. package/build/lib/events/browser-view.js +16 -0
  10. package/build/lib/events/nats-ws-filter.d.ts +8 -8
  11. package/build/lib/events/nats-ws-filter.js +49 -34
  12. package/build/lib/events/nats-ws-proxy.d.ts +5 -0
  13. package/build/lib/events/nats-ws-proxy.js +63 -18
  14. package/build/lib/events/nats-ws-ticket.d.ts +12 -3
  15. package/build/lib/events/nats-ws-ticket.js +25 -4
  16. package/build/modules/config.d.ts +5 -0
  17. package/build/modules/config.js +7 -0
  18. package/build/modules/maintenance.d.ts +2 -0
  19. package/build/modules/maintenance.js +7 -0
  20. package/build/routes/agents.js +4 -3
  21. package/build/routes/oauth-server/authorize.js +6 -4
  22. package/build/routes/yaml-workflows/cron.js +1 -1
  23. package/build/services/agent/authority.d.ts +1 -0
  24. package/build/services/agent/authority.js +7 -1
  25. package/build/services/agent/trigger-registry.js +7 -2
  26. package/build/services/auth/oauth-server/index.d.ts +1 -1
  27. package/build/services/auth/oauth-server/index.js +2 -1
  28. package/build/services/auth/oauth-server/sql.d.ts +16 -0
  29. package/build/services/auth/oauth-server/sql.js +34 -1
  30. package/build/services/auth/oauth-server/store.d.ts +15 -1
  31. package/build/services/auth/oauth-server/store.js +24 -4
  32. package/build/services/cron/index.js +5 -3
  33. package/build/services/maintenance/index.js +8 -0
  34. package/build/services/mcp/seed-service-account.d.ts +4 -1
  35. package/build/services/mcp/seed-service-account.js +11 -6
  36. package/build/services/yaml-workflow/invoke.js +7 -3
  37. package/build/services/yaml-workflow/workers/register.js +3 -13
  38. package/build/services/yaml-workflow/workers/step-tool.d.ts +7 -0
  39. package/build/services/yaml-workflow/workers/step-tool.js +69 -0
  40. package/build/system/mcp-servers/admin/agent-subscriptions.js +2 -1
  41. package/build/system/mcp-servers/admin/schemas.d.ts +6 -6
  42. package/build/system/mcp-servers/admin/yaml-workflows.js +6 -20
  43. package/build/system/mcp-servers/workflow.js +13 -25
  44. package/build/tsconfig.tsbuildinfo +1 -1
  45. package/build/types/maintenance.d.ts +2 -2
  46. package/docs/api/http/mcp-endpoint.md +3 -3
  47. package/docs/maintenance.md +51 -28
  48. 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
- * A compiled workflow's tool steps run with lt-system authority. Deploying or
4
- * activating one needs the caller to be able to call every built-in tool it
5
- * uses: automation never does more than its author may. Null when allowed;
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 compiled workflow's tool steps run with lt-system authority. Deploying or
51
- * activating one needs the caller to be able to call every built-in tool it
52
- * uses: automation never does more than its author may. Null when allowed;
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 deployRefusal(wf, auth) {
56
- if (!auth?.userId)
57
- return null;
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);
@@ -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
- * - PUB and HPUB are dropped with their payloads.
9
- * - SUB, UNSUB, PING and PONG pass through unchanged.
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 NATS client protocol is line-oriented (`OP args\r\n`), and a publish is
12
- * followed by a payload of a declared size. Operations can span WebSocket
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
- /** The line to forward, or null to drop it. */
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
- * - PUB and HPUB are dropped with their payloads.
10
- * - SUB, UNSUB, PING and PONG pass through unchanged.
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 NATS client protocol is line-oriented (`OP args\r\n`), and a publish is
13
- * followed by a payload of a declared size. Operations can span WebSocket
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 CRLF = Buffer.from('\r\n');
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
- if (this.skip > 0) {
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.failed = true;
53
+ return this.fail();
50
54
  break;
51
55
  }
52
- const line = this.pending.subarray(0, end).toString('utf8');
53
- this.pending = this.pending.subarray(end + CRLF.length);
54
- const forwarded = this.filterLine(line);
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
- /** The line to forward, or null to drop it. */
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
- const space = line.indexOf(' ');
65
- const op = (space === -1 ? line : line.slice(0, space)).toUpperCase();
66
- const args = space === -1 ? '' : line.slice(space + 1);
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(args);
69
- if (op === 'PUB' || op === 'HPUB') {
70
- // PUB <subject> [reply] <size>; HPUB <subject> [reply] <header size> <total size>
71
- const size = Number(args.trim().split(/\s+/).pop());
72
- if (!Number.isInteger(size) || size < 0) {
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 line;
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
- server.on('upgrade', async (req, socket, head) => {
44
- const url = new URL(req.url ?? '/', 'http://proxy');
45
- if (url.pathname !== proxyPath)
46
- return;
47
- // Only a signed-in person, holding the ticket the credentials route issued.
48
- const userId = (0, nats_ws_ticket_1.verifyNatsWsTicket)(url.searchParams.get(nats_ws_ticket_1.NATS_WS_TICKET_PARAM));
49
- if (!userId) {
50
- socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
51
- socket.destroy();
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 drops its publishes.
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
- clientWs.close();
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 person a valid ticket names; null for anything else. */
10
- export declare function verifyNatsWsTicket(ticket: string | null | undefined): string | null;
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>;