@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
@@ -6,15 +6,28 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.NATS_WS_TICKET_PARAM = exports.NATS_WS_TICKET_TYPE = void 0;
7
7
  exports.signNatsWsTicket = signNatsWsTicket;
8
8
  exports.verifyNatsWsTicket = verifyNatsWsTicket;
9
+ exports.spendNatsWsTicket = spendNatsWsTicket;
10
+ const crypto_1 = require("crypto");
9
11
  const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
12
+ const db_1 = require("../db");
10
13
  const config_1 = require("../../modules/config");
11
14
  /**
12
15
  * A browser's pass to the NATS WebSocket proxy, in place of the NATS server
13
16
  * credential. It names the person in `sub` and carries no `userId`, so it
14
- * authenticates nothing but the proxy upgrade.
17
+ * authenticates nothing but the proxy upgrade. Each ticket admits one
18
+ * upgrade: the dashboard fetches a fresh one for every connection attempt.
15
19
  */
16
20
  exports.NATS_WS_TICKET_TYPE = 'nats-ws+jwt';
17
21
  exports.NATS_WS_TICKET_PARAM = 'ticket';
22
+ /** Record the ticket as spent, and drop expired records. True only for its first use. */
23
+ const SPEND_TICKET = `
24
+ WITH expired AS (
25
+ DELETE FROM lt_nats_ws_tickets WHERE expires_at < NOW()
26
+ )
27
+ INSERT INTO lt_nats_ws_tickets (jti, expires_at)
28
+ VALUES ($1, to_timestamp($2))
29
+ ON CONFLICT (jti) DO NOTHING
30
+ RETURNING jti`;
18
31
  function signNatsWsTicket(userId) {
19
32
  if (!config_1.config.JWT_SECRET)
20
33
  return null;
@@ -22,10 +35,11 @@ function signNatsWsTicket(userId) {
22
35
  algorithm: 'HS256',
23
36
  header: { alg: 'HS256', typ: exports.NATS_WS_TICKET_TYPE },
24
37
  subject: userId,
38
+ jwtid: (0, crypto_1.randomUUID)(),
25
39
  expiresIn: config_1.config.NATS_WS_TICKET_TTL_SECONDS,
26
40
  });
27
41
  }
28
- /** The person a valid ticket names; null for anything else. */
42
+ /** The claims of a valid ticket; null for anything else. Does not spend it. */
29
43
  function verifyNatsWsTicket(ticket) {
30
44
  if (!ticket || !config_1.config.JWT_SECRET)
31
45
  return null;
@@ -33,10 +47,17 @@ function verifyNatsWsTicket(ticket) {
33
47
  const decoded = jsonwebtoken_1.default.verify(ticket, config_1.config.JWT_SECRET, { algorithms: ['HS256'], complete: true });
34
48
  if (decoded.header.typ !== exports.NATS_WS_TICKET_TYPE)
35
49
  return null;
36
- const payload = decoded.payload;
37
- return typeof payload.sub === 'string' && typeof payload.exp === 'number' ? payload.sub : null;
50
+ const { sub, jti, exp } = decoded.payload;
51
+ if (typeof sub !== 'string' || typeof jti !== 'string' || typeof exp !== 'number')
52
+ return null;
53
+ return { userId: sub, jti, exp };
38
54
  }
39
55
  catch {
40
56
  return null;
41
57
  }
42
58
  }
59
+ /** Spend a verified ticket. False when it was already spent. */
60
+ async function spendNatsWsTicket(ticket) {
61
+ const { rowCount } = await (0, db_1.getPool)().query(SPEND_TICKET, [ticket.jti, ticket.exp]);
62
+ return rowCount === 1;
63
+ }
@@ -12,6 +12,10 @@ export declare const config: {
12
12
  NATS_DASHBOARD_TOKEN: string;
13
13
  /** How long a browser's WebSocket proxy ticket admits an upgrade. The dashboard fetches one per attempt. */
14
14
  NATS_WS_TICKET_TTL_SECONDS: number;
15
+ /** Largest WebSocket frame the proxy accepts from a browser, near NATS's own max_payload. */
16
+ NATS_WS_MAX_PAYLOAD_BYTES: number;
17
+ /** How often the proxy rechecks a connection holder's account; a change closes the connection. */
18
+ NATS_WS_RECHECK_SECONDS: number;
15
19
  /**
16
20
  * Dashboard reconnect: first backoff delay, delay cap, how long a drop lasts
17
21
  * before the banner shows, and the window the first retry and catch-up
@@ -24,6 +28,7 @@ export declare const config: {
24
28
  PORT: number;
25
29
  NODE_ENV: string;
26
30
  JWT_SECRET: string;
31
+ LT_SEED_SERVICE_KEYS: boolean;
27
32
  LT_OAUTH_ISSUER: string;
28
33
  HONEYCOMB_API_KEY: string;
29
34
  ROLE_ENFORCEMENT_CACHE_TTL_MS: number;
@@ -24,6 +24,10 @@ exports.config = {
24
24
  NATS_DASHBOARD_TOKEN: process.env.NATS_DASHBOARD_TOKEN || '',
25
25
  /** How long a browser's WebSocket proxy ticket admits an upgrade. The dashboard fetches one per attempt. */
26
26
  NATS_WS_TICKET_TTL_SECONDS: positiveInt('NATS_WS_TICKET_TTL_SECONDS', 300),
27
+ /** Largest WebSocket frame the proxy accepts from a browser, near NATS's own max_payload. */
28
+ NATS_WS_MAX_PAYLOAD_BYTES: positiveInt('NATS_WS_MAX_PAYLOAD_BYTES', 1024 * 1024),
29
+ /** How often the proxy rechecks a connection holder's account; a change closes the connection. */
30
+ NATS_WS_RECHECK_SECONDS: positiveInt('NATS_WS_RECHECK_SECONDS', 60),
27
31
  /**
28
32
  * Dashboard reconnect: first backoff delay, delay cap, how long a drop lasts
29
33
  * before the banner shows, and the window the first retry and catch-up
@@ -36,6 +40,9 @@ exports.config = {
36
40
  PORT: parseInt(process.env.PORT || '3000', 10),
37
41
  NODE_ENV: process.env.NODE_ENV || 'development',
38
42
  JWT_SECRET: process.env.JWT_SECRET || '',
43
+ // Mint the seeded mcp-service account's API keys and print them to stdout once, at first seed.
44
+ // Local docker compose sets it; elsewhere a superadmin mints keys from the dashboard.
45
+ LT_SEED_SERVICE_KEYS: process.env.LT_SEED_SERVICE_KEYS === 'true',
39
46
  // Enables the OAuth authorization server for /mcp when start() passes no auth.oauthServer.
40
47
  LT_OAUTH_ISSUER: process.env.LT_OAUTH_ISSUER || '',
41
48
  HONEYCOMB_API_KEY: process.env.HONEYCOMB_API_KEY || '',
@@ -10,6 +10,8 @@ import type { LTMaintenanceConfig } from '../types/maintenance';
10
10
  * 3. Strip execution artifacts from entity jobs older than 30 days
11
11
  * (preserves jdata, udata, jmark, hmark — exports always work)
12
12
  * 4. Hard-delete pruned jobs older than 180 days
13
+ * 5. Delete OAuth server rows (expired codes and tokens, unused or revoked
14
+ * grants, clients with no live grant) older than 7 days
13
15
  *
14
16
  * Conservative defaults — execution artifacts (activity inputs, stream
15
17
  * history) are needed for rich export data. Pruning too early strips
@@ -12,6 +12,8 @@ exports.defaultMaintenanceConfig = void 0;
12
12
  * 3. Strip execution artifacts from entity jobs older than 30 days
13
13
  * (preserves jdata, udata, jmark, hmark — exports always work)
14
14
  * 4. Hard-delete pruned jobs older than 180 days
15
+ * 5. Delete OAuth server rows (expired codes and tokens, unused or revoked
16
+ * grants, clients with no live grant) older than 7 days
15
17
  *
16
18
  * Conservative defaults — execution artifacts (activity inputs, stream
17
19
  * history) are needed for rich export data. Pruning too early strips
@@ -44,5 +46,10 @@ exports.defaultMaintenanceConfig = {
44
46
  olderThan: '180 days',
45
47
  pruned: true,
46
48
  },
49
+ {
50
+ target: 'oauth',
51
+ action: 'delete',
52
+ olderThan: '7 days',
53
+ },
47
54
  ],
48
55
  };
@@ -124,13 +124,13 @@ router.get('/:agentId/subscriptions', async (req, res) => {
124
124
  * Create an event subscription for an agent.
125
125
  */
126
126
  router.post('/:agentId/subscriptions', auth_1.requireBuilder, async (req, res) => {
127
- const { topic, reaction_type, execute_as } = req.body;
127
+ const { topic, reaction_type, workflow_type, execute_as } = req.body;
128
128
  if (!topic || !reaction_type) {
129
129
  res.status(400).json({ error: 'topic and reaction_type are required' });
130
130
  return;
131
131
  }
132
132
  const agentId = String(req.params.agentId);
133
- const check = () => (0, authority_1.assertMayConfigureSubscription)(req.auth.userId, { agentId, reaction_type, execute_as });
133
+ const check = () => (0, authority_1.assertMayConfigureSubscription)(req.auth.userId, { agentId, reaction_type, workflow_type, execute_as });
134
134
  if (!(await authorized(check, res)))
135
135
  return;
136
136
  const result = await subApi.createSubscription({ ...req.body, agentId });
@@ -144,7 +144,8 @@ router.put('/:agentId/subscriptions/:subId', auth_1.requireBuilder, async (req,
144
144
  const agentId = String(req.params.agentId);
145
145
  const id = String(req.params.subId);
146
146
  const check = () => (0, authority_1.assertMayConfigureSubscription)(req.auth.userId, {
147
- agentId, subscriptionId: id, reaction_type: req.body?.reaction_type, execute_as: req.body?.execute_as,
147
+ agentId, subscriptionId: id, reaction_type: req.body?.reaction_type,
148
+ workflow_type: req.body?.workflow_type, execute_as: req.body?.execute_as,
148
149
  });
149
150
  if (!(await authorized(check, res)))
150
151
  return;
@@ -51,9 +51,11 @@ function refuseBots(req, res) {
51
51
  * POST /api/oauth/authorize
52
52
  * The person approves. Body: the authorization request plus
53
53
  * { preset: 'read_only' | 'just_me' }. Returns { redirect } for the consent
54
- * page to navigate to, carrying the code.
54
+ * page to navigate to, carrying the code. Only the dashboard's own bearer
55
+ * credential approves: a host session cookie, which any same-site script
56
+ * could send, does not.
55
57
  */
56
- router.post('/authorize', auth_1.requireAuth, async (req, res) => {
58
+ router.post('/authorize', auth_1.requireCredentialAuth, async (req, res) => {
57
59
  if (refuseBots(req, res))
58
60
  return;
59
61
  const settings = (0, oauth_server_1.getOAuthServerSettings)();
@@ -79,9 +81,9 @@ router.post('/authorize', auth_1.requireAuth, async (req, res) => {
79
81
  /**
80
82
  * POST /api/oauth/deny
81
83
  * The person declines. Body: { client_id, redirect_uri, state? }. Returns
82
- * { redirect } carrying error=access_denied.
84
+ * { redirect } carrying error=access_denied. Bearer credential only, as authorize.
83
85
  */
84
- router.post('/deny', auth_1.requireAuth, async (req, res) => {
86
+ router.post('/deny', auth_1.requireCredentialAuth, async (req, res) => {
85
87
  const settings = (0, oauth_server_1.getOAuthServerSettings)();
86
88
  const clientId = typeof req.body?.client_id === 'string' ? req.body.client_id : '';
87
89
  const client = clientId ? await (0, oauth_server_2.getClient)(clientId) : null;
@@ -59,7 +59,7 @@ router.put('/:id/cron', auth_1.requireBuilder, async (req, res) => {
59
59
  cron_schedule: req.body.cron_schedule,
60
60
  cron_envelope: req.body.cron_envelope,
61
61
  execute_as: req.body.execute_as,
62
- });
62
+ }, req.auth);
63
63
  res.status(result.status).json(result.data ?? { error: result.error });
64
64
  });
65
65
  /**
@@ -10,5 +10,6 @@ export declare function assertMayConfigureSubscription(callerId: string, input:
10
10
  agentId: string;
11
11
  subscriptionId?: string;
12
12
  reaction_type?: string;
13
+ workflow_type?: string | null;
13
14
  execute_as?: string | null;
14
15
  }): Promise<void>;
@@ -8,13 +8,16 @@ exports.assertMayConfigureSubscription = assertMayConfigureSubscription;
8
8
  * Whoever configures one must be able to run as that identity: the same rule
9
9
  * as an execute_as override. Automation that would run as lt-system, or a
10
10
  * capability subscription (a tool called with lt-system authority), is a
11
- * superadmin's to configure.
11
+ * superadmin's to configure. A durable subscription starts its workflow
12
+ * under that workflow's invocation rules, for the configurer here and for
13
+ * the run identity each time it fires.
12
14
  */
13
15
  const workflow_invocation_1 = require("../workflow-invocation");
14
16
  const user_1 = require("../user");
15
17
  const index_1 = require("./index");
16
18
  const subscriptions_1 = require("./subscriptions");
17
19
  const SYSTEM_AUTHORITY = 'runs as lt-system; only superadmin can configure it';
20
+ const DURABLE_REACTION = 'durable';
18
21
  async function assertIdentity(callerId, identity, what) {
19
22
  if (!identity)
20
23
  throw new workflow_invocation_1.InvocationError(`${what} has no identity, so it ${SYSTEM_AUTHORITY}`, 403);
@@ -47,6 +50,9 @@ async function assertMayConfigureSubscription(callerId, input) {
47
50
  if (reaction === 'capability') {
48
51
  throw new workflow_invocation_1.InvocationError(`a capability subscription calls a tool directly, so it ${SYSTEM_AUTHORITY}`, 403);
49
52
  }
53
+ const workflowType = input.workflow_type !== undefined ? input.workflow_type : existing?.workflow_type;
54
+ if (reaction === DURABLE_REACTION && workflowType)
55
+ await (0, workflow_invocation_1.checkInvocationRoles)(workflowType, callerId);
50
56
  const agent = await (0, index_1.getAgent)(input.agentId);
51
57
  const executeAs = input.execute_as !== undefined ? input.execute_as : existing?.execute_as;
52
58
  await assertIdentity(callerId, executeAs || agent?.user_id, 'the subscription');
@@ -245,12 +245,17 @@ class AgentTriggerRegistry {
245
245
  async executeReaction(sub, mapped, deterministicId) {
246
246
  switch (sub.reaction_type) {
247
247
  case 'durable': {
248
- const { invokeWorkflow } = await Promise.resolve().then(() => __importStar(require('../workflow-invocation')));
248
+ const { invokeWorkflow, checkInvocationRoles } = await Promise.resolve().then(() => __importStar(require('../workflow-invocation')));
249
+ // The run identity must be able to start this workflow now. Only a
250
+ // superadmin configures a subscription with no identity.
251
+ const runAs = sub.execute_as ?? sub.agent_user_id ?? undefined;
252
+ if (runAs)
253
+ await checkInvocationRoles(sub.workflow_type, runAs);
249
254
  await invokeWorkflow({
250
255
  workflowType: sub.workflow_type,
251
256
  data: mapped.data ?? mapped,
252
257
  metadata: mapped.metadata ?? { source: 'agent', certified: true },
253
- executeAs: sub.execute_as ?? sub.agent_user_id ?? undefined,
258
+ executeAs: runAs,
254
259
  options: { workflowId: deterministicId },
255
260
  auth: { userId: sub.agent_user_id || 'lt-system', role: 'admin' },
256
261
  });
@@ -1,3 +1,3 @@
1
- export { REFRESH_REUSE_GRACE_SECONDS, createSecret, hashSecret, pkceChallenge, registerClient, getClient, issueAuthorizationCode, exchangeAuthorizationCode, rotateRefreshToken, getLiveGrant, revokeByRefreshToken, revokeGrant, listGrants, type RevokedGrant, } from './store';
1
+ export { REFRESH_REUSE_GRACE_SECONDS, createSecret, hashSecret, pkceChallenge, registerClient, getClient, issueAuthorizationCode, exchangeAuthorizationCode, rotateRefreshToken, getLiveGrant, revokeByRefreshToken, revokeGrant, listGrants, pruneOAuthServer, type RevokedGrant, type OAuthPruneCounts, } from './store';
2
2
  export { signAccessToken, verifyAccessToken, accessTokenPrincipal, type AccessTokenTarget, type LTAccessTokenClaims, } from './access-token';
3
3
  export { MCP_SCOPES, PRESET_SCOPE, ACCESS_TOKEN_TYPE, OAUTH_PRINCIPAL_TYPE } from './constants';
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.OAUTH_PRINCIPAL_TYPE = exports.ACCESS_TOKEN_TYPE = exports.PRESET_SCOPE = exports.MCP_SCOPES = exports.accessTokenPrincipal = exports.verifyAccessToken = exports.signAccessToken = exports.listGrants = exports.revokeGrant = exports.revokeByRefreshToken = exports.getLiveGrant = exports.rotateRefreshToken = exports.exchangeAuthorizationCode = exports.issueAuthorizationCode = exports.getClient = exports.registerClient = exports.pkceChallenge = exports.hashSecret = exports.createSecret = exports.REFRESH_REUSE_GRACE_SECONDS = void 0;
3
+ exports.OAUTH_PRINCIPAL_TYPE = exports.ACCESS_TOKEN_TYPE = exports.PRESET_SCOPE = exports.MCP_SCOPES = exports.accessTokenPrincipal = exports.verifyAccessToken = exports.signAccessToken = exports.pruneOAuthServer = exports.listGrants = exports.revokeGrant = exports.revokeByRefreshToken = exports.getLiveGrant = exports.rotateRefreshToken = exports.exchangeAuthorizationCode = exports.issueAuthorizationCode = exports.getClient = exports.registerClient = exports.pkceChallenge = exports.hashSecret = exports.createSecret = exports.REFRESH_REUSE_GRACE_SECONDS = void 0;
4
4
  var store_1 = require("./store");
5
5
  Object.defineProperty(exports, "REFRESH_REUSE_GRACE_SECONDS", { enumerable: true, get: function () { return store_1.REFRESH_REUSE_GRACE_SECONDS; } });
6
6
  Object.defineProperty(exports, "createSecret", { enumerable: true, get: function () { return store_1.createSecret; } });
@@ -15,6 +15,7 @@ Object.defineProperty(exports, "getLiveGrant", { enumerable: true, get: function
15
15
  Object.defineProperty(exports, "revokeByRefreshToken", { enumerable: true, get: function () { return store_1.revokeByRefreshToken; } });
16
16
  Object.defineProperty(exports, "revokeGrant", { enumerable: true, get: function () { return store_1.revokeGrant; } });
17
17
  Object.defineProperty(exports, "listGrants", { enumerable: true, get: function () { return store_1.listGrants; } });
18
+ Object.defineProperty(exports, "pruneOAuthServer", { enumerable: true, get: function () { return store_1.pruneOAuthServer; } });
18
19
  var access_token_1 = require("./access-token");
19
20
  Object.defineProperty(exports, "signAccessToken", { enumerable: true, get: function () { return access_token_1.signAccessToken; } });
20
21
  Object.defineProperty(exports, "verifyAccessToken", { enumerable: true, get: function () { return access_token_1.verifyAccessToken; } });
@@ -26,9 +26,25 @@ export declare const ROTATE_REFRESH_TOKEN = "\n WITH old AS (\n UPDATE lt_oa
26
26
  * outside the retry grace window: the token has leaked.
27
27
  */
28
28
  export declare const REVOKE_ON_REFRESH_REUSE = "\n UPDATE lt_oauth_grants g SET revoked_at = NOW()\n FROM lt_oauth_refresh_tokens t\n WHERE t.token_hash = $1 AND t.used_at IS NOT NULL\n AND t.used_at < NOW() - make_interval(secs => $2)\n AND g.id = t.grant_id AND g.revoked_at IS NULL\n RETURNING g.id AS grant_id, g.user_id";
29
+ /**
30
+ * Revoke the grant of an authorization code presented again after it was
31
+ * exchanged: the code has leaked (RFC 6749 §4.1.2).
32
+ */
33
+ export declare const REVOKE_ON_CODE_REUSE = "\n UPDATE lt_oauth_grants g SET revoked_at = NOW()\n FROM lt_oauth_codes c\n WHERE c.code_hash = $1 AND c.used_at IS NOT NULL\n AND g.id = c.grant_id AND g.revoked_at IS NULL\n RETURNING g.id AS grant_id, g.user_id";
29
34
  /** Revoke the grant a refresh token belongs to, for the client presenting it. */
30
35
  export declare const REVOKE_BY_REFRESH_TOKEN = "\n UPDATE lt_oauth_grants g SET revoked_at = NOW()\n FROM lt_oauth_refresh_tokens t\n WHERE t.token_hash = $1 AND g.id = t.grant_id AND g.client_id = $2 AND g.revoked_at IS NULL\n RETURNING g.id AS grant_id, g.user_id";
31
36
  /** Revoke a grant by id, optionally only when it belongs to the given user. */
32
37
  export declare const REVOKE_GRANT = "\n UPDATE lt_oauth_grants SET revoked_at = NOW()\n WHERE id = $1 AND ($2::uuid IS NULL OR user_id = $2) AND revoked_at IS NULL\n RETURNING id AS grant_id, user_id";
33
38
  /** A person's live grants that a client has put to use, newest first. */
34
39
  export declare const LIST_GRANTS = "\n SELECT g.id AS grant_id, g.client_id, c.client_name, g.policy, g.scope, g.created_at,\n (SELECT MAX(t.created_at) FROM lt_oauth_refresh_tokens t WHERE t.grant_id = g.id) AS last_used_at\n FROM lt_oauth_grants g\n JOIN lt_oauth_clients c ON c.client_id = g.client_id\n WHERE g.user_id = $1 AND g.revoked_at IS NULL\n AND EXISTS (SELECT 1 FROM lt_oauth_refresh_tokens t WHERE t.grant_id = g.id)\n ORDER BY g.created_at DESC";
40
+ /** Refresh tokens past expiry, or rotated out, longer ago than the window. */
41
+ export declare const PRUNE_REFRESH_TOKENS = "\n DELETE FROM lt_oauth_refresh_tokens\n WHERE expires_at < NOW() - $1::interval OR used_at < NOW() - $1::interval";
42
+ /** Authorization codes past expiry longer ago than the window, exchanged or not. */
43
+ export declare const PRUNE_CODES = "\n DELETE FROM lt_oauth_codes WHERE expires_at < NOW() - $1::interval";
44
+ /**
45
+ * Grants revoked longer ago than the window, and grants older than the window
46
+ * left with no refresh token and no code: never exchanged, or long unused.
47
+ */
48
+ export declare const PRUNE_GRANTS = "\n DELETE FROM lt_oauth_grants g\n WHERE g.revoked_at < NOW() - $1::interval\n OR (g.created_at < NOW() - $1::interval\n AND NOT EXISTS (SELECT 1 FROM lt_oauth_refresh_tokens t WHERE t.grant_id = g.id)\n AND NOT EXISTS (SELECT 1 FROM lt_oauth_codes c WHERE c.grant_id = g.id))";
49
+ /** Clients registered longer ago than the window with no live grant. */
50
+ export declare const PRUNE_CLIENTS = "\n DELETE FROM lt_oauth_clients c\n WHERE c.created_at < NOW() - $1::interval\n AND NOT EXISTS (SELECT 1 FROM lt_oauth_grants g WHERE g.client_id = c.client_id AND g.revoked_at IS NULL)";
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  // ── OAuth authorization server queries ─────────────────────────────────────
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
- exports.LIST_GRANTS = exports.REVOKE_GRANT = exports.REVOKE_BY_REFRESH_TOKEN = exports.REVOKE_ON_REFRESH_REUSE = exports.ROTATE_REFRESH_TOKEN = exports.GET_LIVE_GRANT = exports.EXCHANGE_CODE = exports.ISSUE_CODE = exports.GET_CLIENT = exports.INSERT_CLIENT = void 0;
4
+ exports.PRUNE_CLIENTS = exports.PRUNE_GRANTS = exports.PRUNE_CODES = exports.PRUNE_REFRESH_TOKENS = exports.LIST_GRANTS = exports.REVOKE_GRANT = exports.REVOKE_BY_REFRESH_TOKEN = exports.REVOKE_ON_CODE_REUSE = exports.REVOKE_ON_REFRESH_REUSE = exports.ROTATE_REFRESH_TOKEN = exports.GET_LIVE_GRANT = exports.EXCHANGE_CODE = exports.ISSUE_CODE = exports.GET_CLIENT = exports.INSERT_CLIENT = void 0;
5
5
  /** The grant owner's current memberships, as a JSON array. Expects alias `g`. */
6
6
  const GRANT_ROLES = `
7
7
  COALESCE((
@@ -101,6 +101,16 @@ exports.REVOKE_ON_REFRESH_REUSE = `
101
101
  AND t.used_at < NOW() - make_interval(secs => $2)
102
102
  AND g.id = t.grant_id AND g.revoked_at IS NULL
103
103
  RETURNING g.id AS grant_id, g.user_id`;
104
+ /**
105
+ * Revoke the grant of an authorization code presented again after it was
106
+ * exchanged: the code has leaked (RFC 6749 §4.1.2).
107
+ */
108
+ exports.REVOKE_ON_CODE_REUSE = `
109
+ UPDATE lt_oauth_grants g SET revoked_at = NOW()
110
+ FROM lt_oauth_codes c
111
+ WHERE c.code_hash = $1 AND c.used_at IS NOT NULL
112
+ AND g.id = c.grant_id AND g.revoked_at IS NULL
113
+ RETURNING g.id AS grant_id, g.user_id`;
104
114
  /** Revoke the grant a refresh token belongs to, for the client presenting it. */
105
115
  exports.REVOKE_BY_REFRESH_TOKEN = `
106
116
  UPDATE lt_oauth_grants g SET revoked_at = NOW()
@@ -121,3 +131,26 @@ exports.LIST_GRANTS = `
121
131
  WHERE g.user_id = $1 AND g.revoked_at IS NULL
122
132
  AND EXISTS (SELECT 1 FROM lt_oauth_refresh_tokens t WHERE t.grant_id = g.id)
123
133
  ORDER BY g.created_at DESC`;
134
+ // ── Maintenance: each statement takes the retention window as $1 (an interval) ──
135
+ /** Refresh tokens past expiry, or rotated out, longer ago than the window. */
136
+ exports.PRUNE_REFRESH_TOKENS = `
137
+ DELETE FROM lt_oauth_refresh_tokens
138
+ WHERE expires_at < NOW() - $1::interval OR used_at < NOW() - $1::interval`;
139
+ /** Authorization codes past expiry longer ago than the window, exchanged or not. */
140
+ exports.PRUNE_CODES = `
141
+ DELETE FROM lt_oauth_codes WHERE expires_at < NOW() - $1::interval`;
142
+ /**
143
+ * Grants revoked longer ago than the window, and grants older than the window
144
+ * left with no refresh token and no code: never exchanged, or long unused.
145
+ */
146
+ exports.PRUNE_GRANTS = `
147
+ DELETE FROM lt_oauth_grants g
148
+ WHERE g.revoked_at < NOW() - $1::interval
149
+ OR (g.created_at < NOW() - $1::interval
150
+ AND NOT EXISTS (SELECT 1 FROM lt_oauth_refresh_tokens t WHERE t.grant_id = g.id)
151
+ AND NOT EXISTS (SELECT 1 FROM lt_oauth_codes c WHERE c.grant_id = g.id))`;
152
+ /** Clients registered longer ago than the window with no live grant. */
153
+ exports.PRUNE_CLIENTS = `
154
+ DELETE FROM lt_oauth_clients c
155
+ WHERE c.created_at < NOW() - $1::interval
156
+ AND NOT EXISTS (SELECT 1 FROM lt_oauth_grants g WHERE g.client_id = c.client_id AND g.revoked_at IS NULL)`;
@@ -34,7 +34,8 @@ export declare function issueAuthorizationCode(input: {
34
34
  /**
35
35
  * Exchange a code for the grant and its first refresh token. Null when the
36
36
  * code is unknown, used, expired, for another client or redirect URI, fails
37
- * PKCE, or its grant or owner is no longer active.
37
+ * PKCE, or its grant or owner is no longer active. A code presented again
38
+ * after its exchange revokes its grant.
38
39
  */
39
40
  export declare function exchangeAuthorizationCode(input: {
40
41
  code: string;
@@ -72,3 +73,16 @@ export declare function revokeByRefreshToken(refreshToken: string, clientId: str
72
73
  export declare function revokeGrant(grantId: string, userId?: string): Promise<RevokedGrant | null>;
73
74
  /** The person's live grants that a client has put to use. */
74
75
  export declare function listGrants(userId: string): Promise<LTGrantSummary[]>;
76
+ export interface OAuthPruneCounts {
77
+ refreshTokens: number;
78
+ codes: number;
79
+ grants: number;
80
+ clients: number;
81
+ }
82
+ /**
83
+ * Delete what the authorization server no longer needs, older than the
84
+ * window (a Postgres interval): spent and expired refresh tokens and codes,
85
+ * then grants with neither left or revoked, then clients with no live grant.
86
+ * Each step reads what the one before it left.
87
+ */
88
+ export declare function pruneOAuthServer(olderThan: string): Promise<OAuthPruneCounts>;
@@ -46,6 +46,7 @@ exports.getLiveGrant = getLiveGrant;
46
46
  exports.revokeByRefreshToken = revokeByRefreshToken;
47
47
  exports.revokeGrant = revokeGrant;
48
48
  exports.listGrants = listGrants;
49
+ exports.pruneOAuthServer = pruneOAuthServer;
49
50
  const crypto = __importStar(require("crypto"));
50
51
  const db_1 = require("../../../lib/db");
51
52
  const uuid_1 = require("../../../lib/uuid");
@@ -94,16 +95,21 @@ async function issueAuthorizationCode(input) {
94
95
  /**
95
96
  * Exchange a code for the grant and its first refresh token. Null when the
96
97
  * code is unknown, used, expired, for another client or redirect URI, fails
97
- * PKCE, or its grant or owner is no longer active.
98
+ * PKCE, or its grant or owner is no longer active. A code presented again
99
+ * after its exchange revokes its grant.
98
100
  */
99
101
  async function exchangeAuthorizationCode(input) {
102
+ const pool = (0, db_1.getPool)();
100
103
  const refreshToken = createSecret();
101
- const { rows } = await (0, db_1.getPool)().query(sql_1.EXCHANGE_CODE, [
102
- hashSecret(input.code), input.clientId, input.redirectUri, pkceChallenge(input.codeVerifier),
104
+ const codeHash = hashSecret(input.code);
105
+ const { rows } = await pool.query(sql_1.EXCHANGE_CODE, [
106
+ codeHash, input.clientId, input.redirectUri, pkceChallenge(input.codeVerifier),
103
107
  hashSecret(refreshToken), input.refreshTtlSeconds,
104
108
  ]);
105
- if (!rows[0])
109
+ if (!rows[0]) {
110
+ await pool.query(sql_1.REVOKE_ON_CODE_REUSE, [codeHash]);
106
111
  return null;
112
+ }
107
113
  const { resource, ...snapshot } = rows[0];
108
114
  return { snapshot, resource, refreshToken };
109
115
  }
@@ -149,3 +155,17 @@ async function listGrants(userId) {
149
155
  const { rows } = await (0, db_1.getPool)().query(sql_1.LIST_GRANTS, [userId]);
150
156
  return rows;
151
157
  }
158
+ /**
159
+ * Delete what the authorization server no longer needs, older than the
160
+ * window (a Postgres interval): spent and expired refresh tokens and codes,
161
+ * then grants with neither left or revoked, then clients with no live grant.
162
+ * Each step reads what the one before it left.
163
+ */
164
+ async function pruneOAuthServer(olderThan) {
165
+ const pool = (0, db_1.getPool)();
166
+ const refreshTokens = (await pool.query(sql_1.PRUNE_REFRESH_TOKENS, [olderThan])).rowCount ?? 0;
167
+ const codes = (await pool.query(sql_1.PRUNE_CODES, [olderThan])).rowCount ?? 0;
168
+ const grants = (await pool.query(sql_1.PRUNE_GRANTS, [olderThan])).rowCount ?? 0;
169
+ const clients = (await pool.query(sql_1.PRUNE_CLIENTS, [olderThan])).rowCount ?? 0;
170
+ return { refreshTokens, codes, grants, clients };
171
+ }
@@ -65,6 +65,7 @@ function validateCronSchedule(expr) {
65
65
  const CRON_TOPIC_PREFIX = 'lt.cron';
66
66
  const CRON_ID_PREFIX = 'lt-cron';
67
67
  const YAML_CRON_TOPIC_PREFIX = 'lt.cron.yaml';
68
+ const CRON_SOURCE = 'cron';
68
69
  const YAML_CRON_ID_PREFIX = 'lt-cron-yaml';
69
70
  /**
70
71
  * Singleton registry for workflow cron schedules.
@@ -241,9 +242,10 @@ class LTCronRegistry {
241
242
  try {
242
243
  cronCallCount++;
243
244
  logger_1.loggerRegistry.info(`[lt-cron] tick #${cronCallCount} at ${new Date().toISOString()} — pub(${appId}/${graphTopic})`);
244
- const { getEngine } = await Promise.resolve().then(() => __importStar(require('../yaml-workflow/deployer')));
245
- const engine = await getEngine(appId);
246
- await engine.pub(graphTopic, cronEnvelope, undefined, { entity: graphTopic });
245
+ // Through the invoke service, so the run carries only the scope the
246
+ // server resolves for execute_as, never one written in the envelope.
247
+ const { invokeYamlWorkflow } = await Promise.resolve().then(() => __importStar(require('../yaml-workflow/invoke')));
248
+ await invokeYamlWorkflow(wf, { data: cronEnvelope, execute_as: wf.execute_as ?? undefined, source: CRON_SOURCE });
247
249
  logger_1.loggerRegistry.info(`[lt-cron] tick #${cronCallCount} published successfully`);
248
250
  }
249
251
  catch (err) {
@@ -106,6 +106,14 @@ async function executeRule(appId, rule) {
106
106
  }
107
107
  break;
108
108
  }
109
+ // Drop the authorization server's dead rows: spent or expired tokens and
110
+ // codes, grants with neither left or long revoked, clients with no live grant.
111
+ case rule.target === 'oauth' && rule.action === 'delete': {
112
+ const { pruneOAuthServer } = await Promise.resolve().then(() => __importStar(require('../auth/oauth-server')));
113
+ const counts = await pruneOAuthServer(rule.olderThan);
114
+ logger_1.loggerRegistry.info(`[lt-maintenance] oauth pruned: ${JSON.stringify(counts)} (olderThan: ${rule.olderThan})`);
115
+ break;
116
+ }
109
117
  default:
110
118
  logger_1.loggerRegistry.warn(`[lt-maintenance] unknown rule, skipping: ${JSON.stringify(rule)}`);
111
119
  }
@@ -6,6 +6,9 @@
6
6
  * - `mcp:full` — all tools (automation, orchestration)
7
7
  *
8
8
  * Idempotent — skips creation if the account already exists.
9
- * Keys are generated once and logged; they cannot be retrieved again.
9
+ * With LT_SEED_SERVICE_KEYS=true (local docker compose) the keys are
10
+ * generated once and printed to stdout, never to the logger; they cannot be
11
+ * retrieved again. Otherwise the account is created without keys and a
12
+ * superadmin mints them from the dashboard or the bot-accounts API.
10
13
  */
11
14
  export declare function seedMcpServiceAccount(): Promise<void>;
@@ -7,7 +7,10 @@
7
7
  * - `mcp:full` — all tools (automation, orchestration)
8
8
  *
9
9
  * Idempotent — skips creation if the account already exists.
10
- * Keys are generated once and logged; they cannot be retrieved again.
10
+ * With LT_SEED_SERVICE_KEYS=true (local docker compose) the keys are
11
+ * generated once and printed to stdout, never to the logger; they cannot be
12
+ * retrieved again. Otherwise the account is created without keys and a
13
+ * superadmin mints them from the dashboard or the bot-accounts API.
11
14
  */
12
15
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
13
16
  if (k2 === undefined) k2 = k;
@@ -45,6 +48,7 @@ var __importStar = (this && this.__importStar) || (function () {
45
48
  Object.defineProperty(exports, "__esModule", { value: true });
46
49
  exports.seedMcpServiceAccount = seedMcpServiceAccount;
47
50
  const logger_1 = require("../../lib/logger");
51
+ const config_1 = require("../../modules/config");
48
52
  const crud_1 = require("../user/crud");
49
53
  const iam = __importStar(require("../iam"));
50
54
  const SERVICE_ACCOUNT_NAME = 'mcp-service';
@@ -63,13 +67,14 @@ async function seedMcpServiceAccount() {
63
67
  display_name: 'MCP Service',
64
68
  roles: [{ role: 'system', type: 'member' }],
65
69
  });
66
- // Generate read key (scoped to mcp:read)
70
+ if (!config_1.config.LT_SEED_SERVICE_KEYS) {
71
+ logger_1.loggerRegistry.info(`[seed-mcp] ${SERVICE_ACCOUNT_NAME} created; mint its API keys from the dashboard`);
72
+ return;
73
+ }
67
74
  const readKey = await iam.createBotKey(bot.id, 'read', ['mcp:read']);
68
- logger_1.loggerRegistry.info(`[seed-mcp] read key: ${readKey.rawKey}`);
69
- // Generate full key (scoped to mcp:full)
70
75
  const fullKey = await iam.createBotKey(bot.id, 'full', ['mcp:read', 'mcp:full']);
71
- logger_1.loggerRegistry.info(`[seed-mcp] full key: ${fullKey.rawKey}`);
72
- logger_1.loggerRegistry.info(`[seed-mcp] ${SERVICE_ACCOUNT_NAME} created with read + full API keys`);
76
+ process.stdout.write(`[seed-mcp] read key: ${readKey.rawKey}\n[seed-mcp] full key: ${fullKey.rawKey}\n`);
77
+ logger_1.loggerRegistry.info(`[seed-mcp] ${SERVICE_ACCOUNT_NAME} created with read + full API keys (printed to stdout)`);
73
78
  }
74
79
  catch (err) {
75
80
  logger_1.loggerRegistry.warn(`[seed-mcp] failed to seed: ${err.message}`);
@@ -36,11 +36,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.invokeYamlWorkflow = invokeYamlWorkflow;
37
37
  const yamlDeployer = __importStar(require("./deployer"));
38
38
  const principal_1 = require("../iam/principal");
39
+ const workflow_invocation_1 = require("../workflow-invocation");
39
40
  const publish_1 = require("../../lib/events/publish");
40
41
  /**
41
42
  * The identity context compiled workflow activities run under, from the
42
43
  * server-side identity: execute_as with the invoker as initiator, else the
43
- * invoker. Never read from the caller's data.
44
+ * invoker. Never read from the caller's data. An identity that does not
45
+ * resolve is refused rather than run with none.
44
46
  */
45
47
  async function resolveScope(executeAs, userId) {
46
48
  if (executeAs) {
@@ -49,7 +51,7 @@ async function resolveScope(executeAs, userId) {
49
51
  userId ? (0, principal_1.resolvePrincipal)(userId) : Promise.resolve(null),
50
52
  ]);
51
53
  if (!botPrincipal)
52
- return undefined;
54
+ throw new workflow_invocation_1.InvocationError(`execute_as principal "${executeAs}" not found`, 404);
53
55
  return {
54
56
  principal: botPrincipal,
55
57
  scopes: ['mcp:tool:call'],
@@ -59,7 +61,9 @@ async function resolveScope(executeAs, userId) {
59
61
  if (!userId)
60
62
  return undefined;
61
63
  const principal = await (0, principal_1.resolvePrincipal)(userId);
62
- return principal ? { principal, scopes: ['mcp:tool:call'] } : undefined;
64
+ if (!principal)
65
+ throw new workflow_invocation_1.InvocationError('User not registered', 403);
66
+ return { principal, scopes: ['mcp:tool:call'] };
63
67
  }
64
68
  /**
65
69
  * Invoke a YAML workflow with scope injection.
@@ -40,10 +40,9 @@ const db_1 = require("../../../lib/db");
40
40
  const logger_1 = require("../../../lib/logger");
41
41
  const system_events_1 = require("../../../lib/events/system-events");
42
42
  const ephemeral_1 = require("../../iam/ephemeral");
43
- const mcpClient = __importStar(require("../../mcp/client"));
44
- const connection_1 = require("../../mcp/client/connection");
45
43
  const yamlDb = __importStar(require("../db"));
46
44
  const scope_1 = require("./scope");
45
+ const step_tool_1 = require("./step-tool");
47
46
  const callbacks_1 = require("./callbacks");
48
47
  const events_1 = require("./events");
49
48
  /**
@@ -156,7 +155,7 @@ async function registerWorkersForWorkflow(workflow) {
156
155
  delete mergedArgs._scope;
157
156
  delete mergedArgs.workflowName;
158
157
  mergedArgs = await (0, ephemeral_1.exchangeTokensInArgs)(mergedArgs);
159
- const result = await mcpClient.callServerTool(dbServerId, toolName, mergedArgs);
158
+ const result = await (0, step_tool_1.callStepTool)(dbServerId, toolName, mergedArgs);
160
159
  logger_1.loggerRegistry.debug(`[yaml-workflow:worker] leaving db/${toolName} wf=${wfName} resultKeys=[${Object.keys(result || {}).join(',')}]`);
161
160
  return { metadata: { ...data.metadata }, data: result };
162
161
  }),
@@ -230,16 +229,7 @@ async function registerWorkersForWorkflow(workflow) {
230
229
  }
231
230
  const exchangedArgs = await (0, ephemeral_1.exchangeTokensInArgs)(mergedArgs);
232
231
  const coercedArgs = coerceNumericObjects(exchangedArgs);
233
- // Try direct dispatch for built-in servers (bypasses MCP transport).
234
- // Falls through to mcpClient.callServerTool() for external servers.
235
- let result;
236
- const builtin = await (0, connection_1.dispatchBuiltinTool)(serverId, toolName, coercedArgs);
237
- if (builtin) {
238
- result = builtin.result;
239
- }
240
- else {
241
- result = await mcpClient.callServerTool(serverId, toolName, coercedArgs);
242
- }
232
+ const result = await (0, step_tool_1.callStepTool)(serverId, toolName, coercedArgs);
243
233
  if (result && typeof result === 'object' && 'error' in result) {
244
234
  logger_1.loggerRegistry.error(`[yaml-workflow:worker] ${toolName} error: ${JSON.stringify(result).slice(0, 200)}`);
245
235
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Call a step's tool. A built-in tool runs as the principal the workflow was
3
+ * started for (its `_scope`), under the tool's manifest gate, so a step never
4
+ * does more than its invoker may. A run started in-process with no principal
5
+ * calls it as lt-system. Other servers are called as before.
6
+ */
7
+ export declare function callStepTool(serverId: string, toolName: string, args: Record<string, unknown>): Promise<any>;