@hotmeshio/long-tail 0.4.20 → 0.4.22

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.
@@ -1,3 +1,4 @@
1
+ import { type ProvisionIfAbsent } from './helpers';
1
2
  import type { LTApiResult, LTApiAuth } from '../../types/sdk';
2
3
  /**
3
4
  * Claim a pending escalation for the authenticated user.
@@ -14,6 +15,7 @@ import type { LTApiResult, LTApiAuth } from '../../types/sdk';
14
15
  export declare function claimEscalation(input: {
15
16
  id: string;
16
17
  durationMinutes?: number;
18
+ provisionIfAbsent?: ProvisionIfAbsent;
17
19
  }, auth: LTApiAuth): Promise<LTApiResult>;
18
20
  /**
19
21
  * Release a claimed escalation back to the pool.
@@ -52,26 +52,30 @@ const helpers_1 = require("./helpers");
52
52
  */
53
53
  async function claimEscalation(input, auth) {
54
54
  try {
55
- const { id, durationMinutes } = input;
55
+ const { id, durationMinutes, provisionIfAbsent } = input;
56
56
  const escalation = await escalationService.getEscalation(id);
57
57
  if (!escalation) {
58
58
  return { status: 404, error: 'Escalation not found' };
59
59
  }
60
+ // Happy path: try claim assuming user has the role
60
61
  const hasGlobal = await (0, helpers_1.hasGlobalEscalationAccess)(auth.userId);
61
62
  if (!hasGlobal) {
62
63
  const userHasRole = await userService.hasRole(auth.userId, escalation.role);
63
64
  if (!userHasRole) {
64
- return {
65
- status: 403,
66
- error: `You must have the "${escalation.role}" role to claim this escalation`,
67
- };
65
+ // Unhappy path: provision role if flag present, then retry
66
+ const provisioned = await (0, helpers_1.ensureRoleMembership)(auth.userId, escalation.role, auth.userId, provisionIfAbsent);
67
+ if (!provisioned) {
68
+ return {
69
+ status: 403,
70
+ error: `You must have the "${escalation.role}" role to claim this escalation`,
71
+ };
72
+ }
68
73
  }
69
74
  }
70
75
  const result = await escalationService.claimEscalation(id, auth.userId, durationMinutes);
71
76
  if (!result) {
72
77
  return { status: 409, error: 'Escalation not available for claim' };
73
78
  }
74
- // Event published by service layer (services/escalation/crud.ts)
75
79
  return { status: 200, data: result };
76
80
  }
77
81
  catch (err) {
@@ -8,13 +8,30 @@ export declare function checkBulkPermission(userId: string, ids: string[]): Prom
8
8
  status: 403;
9
9
  error: string;
10
10
  }>;
11
+ /**
12
+ * Identity to provision when the assignee doesn't exist in lt_users
13
+ * or lacks the required role. Only honored for callers with global
14
+ * escalation access (superadmin, admin/admin).
15
+ */
16
+ export interface ProvisionIfAbsent {
17
+ displayName?: string;
18
+ email?: string;
19
+ roles?: Array<{
20
+ role: string;
21
+ type?: string;
22
+ }>;
23
+ }
11
24
  /**
12
25
  * Resolve an optional assignee external_id to an internal userId.
13
26
  * When omitted, returns the caller's userId from auth.
27
+ *
28
+ * When `provisionIfAbsent` is provided and the caller has global access,
29
+ * the user is JIT-provisioned if absent and roles are ensured. This avoids
30
+ * pre-flight queries — the happy path (user exists) is one lookup.
14
31
  */
15
32
  export declare function resolveAssignee(assignee: string | undefined, auth: {
16
33
  userId: string;
17
- }): Promise<{
34
+ }, provisionIfAbsent?: ProvisionIfAbsent): Promise<{
18
35
  userId: string;
19
36
  } | {
20
37
  error: {
@@ -22,4 +39,10 @@ export declare function resolveAssignee(assignee: string | undefined, auth: {
22
39
  error: string;
23
40
  };
24
41
  }>;
42
+ /**
43
+ * Ensure a user has the required role for an escalation.
44
+ * Called after claim when the atomic SQL returns null due to role mismatch.
45
+ * Only adds the role if `provisionIfAbsent` declares it and caller has authority.
46
+ */
47
+ export declare function ensureRoleMembership(userId: string, requiredRole: string, callerUserId: string, provisionIfAbsent?: ProvisionIfAbsent): Promise<boolean>;
25
48
  export declare function publishBulkClaimEvents(ids: string[], assignedTo: string): void;
@@ -38,6 +38,7 @@ exports.getVisibleRoles = getVisibleRoles;
38
38
  exports.validateIds = validateIds;
39
39
  exports.checkBulkPermission = checkBulkPermission;
40
40
  exports.resolveAssignee = resolveAssignee;
41
+ exports.ensureRoleMembership = ensureRoleMembership;
41
42
  exports.publishBulkClaimEvents = publishBulkClaimEvents;
42
43
  const escalationService = __importStar(require("../../services/escalation"));
43
44
  const userService = __importStar(require("../../services/user"));
@@ -71,15 +72,59 @@ async function checkBulkPermission(userId, ids) {
71
72
  /**
72
73
  * Resolve an optional assignee external_id to an internal userId.
73
74
  * When omitted, returns the caller's userId from auth.
75
+ *
76
+ * When `provisionIfAbsent` is provided and the caller has global access,
77
+ * the user is JIT-provisioned if absent and roles are ensured. This avoids
78
+ * pre-flight queries — the happy path (user exists) is one lookup.
74
79
  */
75
- async function resolveAssignee(assignee, auth) {
80
+ async function resolveAssignee(assignee, auth, provisionIfAbsent) {
76
81
  if (!assignee)
77
82
  return { userId: auth.userId };
83
+ // Happy path: user exists
78
84
  const user = await userService.getUserByExternalId(assignee);
79
- if (!user) {
85
+ if (user)
86
+ return { userId: user.id };
87
+ // User not found — provision if caller has authority and flag is set
88
+ if (!provisionIfAbsent) {
80
89
  return { error: { status: 404, error: `User not found for external_id: ${assignee}` } };
81
90
  }
82
- return { userId: user.id };
91
+ const hasAuthority = await userService.hasGlobalEscalationAccess(auth.userId);
92
+ if (!hasAuthority) {
93
+ return { error: { status: 403, error: 'Only superadmin or admin can provision users on claim' } };
94
+ }
95
+ // Provision the user
96
+ const created = await userService.createUser({
97
+ external_id: assignee,
98
+ display_name: provisionIfAbsent.displayName || assignee,
99
+ email: provisionIfAbsent.email,
100
+ roles: (provisionIfAbsent.roles || []).map((r) => ({
101
+ role: r.role,
102
+ type: (r.type || 'member'),
103
+ })),
104
+ });
105
+ return { userId: created.id };
106
+ }
107
+ /**
108
+ * Ensure a user has the required role for an escalation.
109
+ * Called after claim when the atomic SQL returns null due to role mismatch.
110
+ * Only adds the role if `provisionIfAbsent` declares it and caller has authority.
111
+ */
112
+ async function ensureRoleMembership(userId, requiredRole, callerUserId, provisionIfAbsent) {
113
+ if (!provisionIfAbsent)
114
+ return false;
115
+ const hasAuthority = await userService.hasGlobalEscalationAccess(callerUserId);
116
+ if (!hasAuthority)
117
+ return false;
118
+ // Check if the provision declares this role
119
+ const declaredRole = provisionIfAbsent.roles?.find((r) => r.role === requiredRole);
120
+ if (!declaredRole)
121
+ return false;
122
+ // Add the role — idempotent (ON CONFLICT DO NOTHING)
123
+ try {
124
+ await userService.addUserRole(userId, requiredRole, (declaredRole.type || 'member'));
125
+ }
126
+ catch { /* already has it */ }
127
+ return true;
83
128
  }
84
129
  function publishBulkClaimEvents(ids, assignedTo) {
85
130
  for (const id of ids) {
@@ -1,3 +1,4 @@
1
+ import { type ProvisionIfAbsent } from './helpers';
1
2
  import type { LTApiAuth, LTApiResult } from '../../types/sdk';
2
3
  /**
3
4
  * Find escalations by a metadata key-value pair.
@@ -25,12 +26,19 @@ export declare function claimByMetadata(input: {
25
26
  durationMinutes?: number;
26
27
  assignee?: string;
27
28
  metadata?: Record<string, any>;
29
+ provisionIfAbsent?: ProvisionIfAbsent;
28
30
  }, auth: LTApiAuth): Promise<LTApiResult>;
29
31
  /**
30
32
  * Resolve an escalation by metadata key-value pair.
31
33
  *
32
- * Single atomic CTE: find + claim + resolve in one query.
33
- * RBAC is enforced in the SQL WHERE clause.
34
+ * Single atomic query with signal guard:
35
+ * - No signal_id → claim + resolve atomically in SQL. One query. Done.
36
+ * - signal_id present → SQL returns the signal info without resolving.
37
+ * Caller signals the workflow; conditionLT resolves durably inside
38
+ * the workflow via ltResolveEscalation.
39
+ *
40
+ * Never does SELECT-then-UPDATE. The SQL CTE handles find + RBAC +
41
+ * claim + resolve (or signal detection) in one round-trip.
34
42
  */
35
43
  export declare function resolveByMetadata(input: {
36
44
  key: string;
@@ -76,7 +76,7 @@ async function claimByMetadata(input, auth) {
76
76
  if (!input.key || !input.value) {
77
77
  return { status: 400, error: 'key and value are required' };
78
78
  }
79
- const resolved = await (0, helpers_1.resolveAssignee)(input.assignee, auth);
79
+ const resolved = await (0, helpers_1.resolveAssignee)(input.assignee, auth, input.provisionIfAbsent);
80
80
  if ('error' in resolved)
81
81
  return resolved.error;
82
82
  const claimUserId = resolved.userId;
@@ -99,8 +99,14 @@ async function claimByMetadata(input, auth) {
99
99
  /**
100
100
  * Resolve an escalation by metadata key-value pair.
101
101
  *
102
- * Single atomic CTE: find + claim + resolve in one query.
103
- * RBAC is enforced in the SQL WHERE clause.
102
+ * Single atomic query with signal guard:
103
+ * - No signal_id → claim + resolve atomically in SQL. One query. Done.
104
+ * - signal_id present → SQL returns the signal info without resolving.
105
+ * Caller signals the workflow; conditionLT resolves durably inside
106
+ * the workflow via ltResolveEscalation.
107
+ *
108
+ * Never does SELECT-then-UPDATE. The SQL CTE handles find + RBAC +
109
+ * claim + resolve (or signal detection) in one round-trip.
104
110
  */
105
111
  async function resolveByMetadata(input, auth) {
106
112
  try {
@@ -115,11 +121,25 @@ async function resolveByMetadata(input, auth) {
115
121
  return resolved.error;
116
122
  const resolveUserId = resolved.userId;
117
123
  const allowedRoles = await resolveAllowedRoles(auth.userId);
118
- const escalation = await escalationService.resolveByMetadataAtomic(input.key, input.value, resolveUserId, input.resolverPayload, input.metadata, allowedRoles);
119
- if (!escalation) {
124
+ const result = await escalationService.resolveByMetadataAtomic(input.key, input.value, resolveUserId, input.resolverPayload, input.metadata, allowedRoles);
125
+ if (result.outcome === 'not_found') {
120
126
  return { status: 404, error: 'No pending escalation found for this metadata, or insufficient role permissions' };
121
127
  }
122
- return { status: 200, data: { escalation } };
128
+ if (result.outcome === 'resolved') {
129
+ return { status: 200, data: { escalation: result.escalation } };
130
+ }
131
+ // Signal-backed escalation — signal the workflow, conditionLT resolves durably
132
+ const { createClient } = await Promise.resolve().then(() => __importStar(require('../../workers')));
133
+ const client = createClient();
134
+ const handle = await client.workflow.getHandle(result.taskQueue, result.workflowType, result.workflowId);
135
+ await handle.signal(result.signalId, {
136
+ ...input.resolverPayload,
137
+ $escalation_id: result.escalationId,
138
+ });
139
+ return {
140
+ status: 200,
141
+ data: { signaled: true, escalationId: result.escalationId, workflowId: result.workflowId },
142
+ };
123
143
  }
124
144
  catch (err) {
125
145
  return { status: 500, error: err.message };
@@ -134,5 +154,8 @@ async function resolveAllowedRoles(userId) {
134
154
  if (await userService.hasGlobalEscalationAccess(userId))
135
155
  return null;
136
156
  const userRoles = await userService.getUserRoles(userId);
157
+ // Return the user's roles (may be empty → SQL filters out all rows).
158
+ // System/service accounts that need unrestricted access should be
159
+ // seeded with the superadmin role via start({ seed: { admin } }).
137
160
  return userRoles.map(r => r.role);
138
161
  }
@@ -55,7 +55,7 @@ export declare const requireAdmin: RequestHandler;
55
55
  /**
56
56
  * Middleware that requires builder access. Must be placed AFTER requireAuth.
57
57
  *
58
- * Builders are superadmin, admin, or users with the 'engineer' role.
58
+ * Builders are superadmin or users with the 'engineer' role.
59
59
  * This is the backend equivalent of the dashboard's `isBuilder` check.
60
60
  */
61
61
  export declare const requireBuilder: RequestHandler;
@@ -225,7 +225,7 @@ exports.requireAdmin = requireAdmin;
225
225
  /**
226
226
  * Middleware that requires builder access. Must be placed AFTER requireAuth.
227
227
  *
228
- * Builders are superadmin, admin, or users with the 'engineer' role.
228
+ * Builders are superadmin or users with the 'engineer' role.
229
229
  * This is the backend equivalent of the dashboard's `isBuilder` check.
230
230
  */
231
231
  const requireBuilder = async (req, res, next) => {
@@ -234,8 +234,8 @@ const requireBuilder = async (req, res, next) => {
234
234
  res.status(403).json({ error: 'Forbidden' });
235
235
  return;
236
236
  }
237
- // Fast path: trust the JWT role claim for admin/superadmin
238
- if (req.auth.role === 'admin' || req.auth.role === 'superadmin') {
237
+ // Fast path: trust the JWT role claim for superadmin
238
+ if (req.auth.role === 'superadmin') {
239
239
  next();
240
240
  return;
241
241
  }
@@ -58,7 +58,7 @@ function registerMetadataRoutes(router) {
58
58
  /**
59
59
  * POST /api/escalations/claim-by-metadata
60
60
  * Find and claim an escalation by metadata key-value pair.
61
- * Body: { key, value, durationMinutes?, assignee?, metadata? }
61
+ * Body: { key, value, durationMinutes?, assignee?, metadata?, provisionIfAbsent? }
62
62
  */
63
63
  router.post('/claim-by-metadata', async (req, res) => {
64
64
  const result = await api.claimByMetadata({
@@ -67,6 +67,7 @@ function registerMetadataRoutes(router) {
67
67
  durationMinutes: req.body?.durationMinutes,
68
68
  assignee: req.body?.assignee,
69
69
  metadata: req.body?.metadata,
70
+ provisionIfAbsent: req.body?.provisionIfAbsent,
70
71
  }, req.auth);
71
72
  res.status(result.status).json(result.data ?? { error: result.error });
72
73
  });
@@ -71,7 +71,7 @@ function registerSingleRoutes(router) {
71
71
  * Body: { durationMinutes?: number }
72
72
  */
73
73
  router.post('/:id/claim', async (req, res) => {
74
- const result = await api.claimEscalation({ id: req.params.id, durationMinutes: req.body?.durationMinutes }, req.auth);
74
+ const result = await api.claimEscalation({ id: req.params.id, durationMinutes: req.body?.durationMinutes, provisionIfAbsent: req.body?.provisionIfAbsent }, req.auth);
75
75
  res.status(result.status).json(result.data ?? { error: result.error });
76
76
  });
77
77
  /**
@@ -121,6 +121,7 @@ export declare function createClient(options?: LTClientOptions): {
121
121
  claim: (input: {
122
122
  id: string;
123
123
  durationMinutes?: number;
124
+ provisionIfAbsent?: import("../api/escalations/helpers").ProvisionIfAbsent;
124
125
  }, auth?: LTApiAuth) => Promise<LTApiResult<any>>;
125
126
  release: (input: {
126
127
  id: string;
@@ -164,6 +165,7 @@ export declare function createClient(options?: LTClientOptions): {
164
165
  durationMinutes?: number;
165
166
  assignee?: string;
166
167
  metadata?: Record<string, any>;
168
+ provisionIfAbsent?: import("../api/escalations/helpers").ProvisionIfAbsent;
167
169
  }, auth?: LTApiAuth) => Promise<LTApiResult<any>>;
168
170
  resolveByMetadata: (input: {
169
171
  key: string;
@@ -43,26 +43,8 @@ exports.listBotApiKeys = listBotApiKeys;
43
43
  const crypto = __importStar(require("crypto"));
44
44
  const bcryptjs_1 = __importDefault(require("bcryptjs"));
45
45
  const db_1 = require("../../lib/db");
46
+ const sql_1 = require("./sql");
46
47
  const TOKEN_PREFIX = 'lt_bot_';
47
- const INSERT_KEY = `
48
- INSERT INTO lt_bot_api_keys (name, user_id, key_hash, scopes, expires_at)
49
- VALUES ($1, $2, $3, $4, $5) RETURNING id`;
50
- const GET_KEYS_BY_USER = `
51
- SELECT id, name, user_id, key_hash, scopes
52
- FROM lt_bot_api_keys
53
- WHERE user_id = $1
54
- AND (expires_at IS NULL OR expires_at > NOW())`;
55
- const GET_ALL_ACTIVE_KEYS = `
56
- SELECT id, name, user_id, key_hash, scopes
57
- FROM lt_bot_api_keys
58
- WHERE (expires_at IS NULL OR expires_at > NOW())`;
59
- const UPDATE_LAST_USED = `
60
- UPDATE lt_bot_api_keys SET last_used_at = NOW() WHERE id = $1`;
61
- const DELETE_KEY = `
62
- DELETE FROM lt_bot_api_keys WHERE id = $1`;
63
- const LIST_BY_USER = `
64
- SELECT id, name, user_id, scopes, expires_at, last_used_at, created_at, updated_at
65
- FROM lt_bot_api_keys WHERE user_id = $1 ORDER BY created_at`;
66
48
  /**
67
49
  * Generate a new API key for a bot account.
68
50
  * Returns the raw key once — it is never stored in plaintext.
@@ -71,7 +53,7 @@ async function generateBotApiKey(name, userId, scopes = [], expiresAt) {
71
53
  const rawKey = `${TOKEN_PREFIX}${crypto.randomBytes(32).toString('hex')}`;
72
54
  const keyHash = await bcryptjs_1.default.hash(rawKey, 10);
73
55
  const pool = await (0, db_1.getPool)();
74
- const { rows } = await pool.query(INSERT_KEY, [
56
+ const { rows } = await pool.query(sql_1.INSERT_BOT_KEY, [
75
57
  name, userId, keyHash, scopes, expiresAt || null,
76
58
  ]);
77
59
  return { id: rows[0].id, rawKey };
@@ -84,10 +66,10 @@ async function validateBotApiKey(rawKey) {
84
66
  if (!rawKey.startsWith(TOKEN_PREFIX))
85
67
  return null;
86
68
  const pool = await (0, db_1.getPool)();
87
- const { rows } = await pool.query(GET_ALL_ACTIVE_KEYS);
69
+ const { rows } = await pool.query(sql_1.GET_ALL_ACTIVE_BOT_KEYS);
88
70
  for (const row of rows) {
89
71
  if (await bcryptjs_1.default.compare(rawKey, row.key_hash)) {
90
- await pool.query(UPDATE_LAST_USED, [row.id]);
72
+ await pool.query(sql_1.UPDATE_BOT_KEY_LAST_USED, [row.id]);
91
73
  const { key_hash, ...record } = row;
92
74
  return record;
93
75
  }
@@ -99,7 +81,7 @@ async function validateBotApiKey(rawKey) {
99
81
  */
100
82
  async function revokeBotApiKey(id) {
101
83
  const pool = await (0, db_1.getPool)();
102
- const result = await pool.query(DELETE_KEY, [id]);
84
+ const result = await pool.query(sql_1.DELETE_BOT_KEY, [id]);
103
85
  return (result.rowCount ?? 0) > 0;
104
86
  }
105
87
  /**
@@ -107,6 +89,6 @@ async function revokeBotApiKey(id) {
107
89
  */
108
90
  async function listBotApiKeys(userId) {
109
91
  const pool = await (0, db_1.getPool)();
110
- const { rows } = await pool.query(LIST_BY_USER, [userId]);
92
+ const { rows } = await pool.query(sql_1.LIST_BOT_KEYS_BY_USER, [userId]);
111
93
  return rows;
112
94
  }
@@ -43,20 +43,8 @@ exports.listServiceTokens = listServiceTokens;
43
43
  const crypto = __importStar(require("crypto"));
44
44
  const bcryptjs_1 = __importDefault(require("bcryptjs"));
45
45
  const db_1 = require("../../lib/db");
46
+ const sql_1 = require("./sql");
46
47
  const TOKEN_PREFIX = 'lt_svc_';
47
- const INSERT_TOKEN = `
48
- INSERT INTO lt_service_tokens (name, token_hash, server_id, scopes, expires_at)
49
- VALUES ($1, $2, $3, $4, $5) RETURNING id`;
50
- const GET_ALL_TOKENS = `
51
- SELECT id, name, token_hash FROM lt_service_tokens
52
- WHERE (expires_at IS NULL OR expires_at > NOW())`;
53
- const UPDATE_LAST_USED = `
54
- UPDATE lt_service_tokens SET last_used_at = NOW() WHERE id = $1`;
55
- const DELETE_TOKEN = `
56
- DELETE FROM lt_service_tokens WHERE id = $1`;
57
- const LIST_BY_SERVER = `
58
- SELECT id, name, server_id, scopes, expires_at, last_used_at, created_at, updated_at
59
- FROM lt_service_tokens WHERE server_id = $1 ORDER BY created_at`;
60
48
  /**
61
49
  * Generate a new service token for an external MCP server.
62
50
  * Returns the raw token once — it is never stored in plaintext.
@@ -65,7 +53,7 @@ async function generateServiceToken(name, serverId, scopes, expiresAt) {
65
53
  const rawToken = `${TOKEN_PREFIX}${crypto.randomBytes(32).toString('hex')}`;
66
54
  const tokenHash = await bcryptjs_1.default.hash(rawToken, 10);
67
55
  const pool = await (0, db_1.getPool)();
68
- const { rows } = await pool.query(INSERT_TOKEN, [
56
+ const { rows } = await pool.query(sql_1.INSERT_SERVICE_TOKEN, [
69
57
  name, tokenHash, serverId, scopes, expiresAt || null,
70
58
  ]);
71
59
  return { id: rows[0].id, rawToken };
@@ -77,10 +65,10 @@ async function validateServiceToken(rawToken) {
77
65
  if (!rawToken.startsWith(TOKEN_PREFIX))
78
66
  return null;
79
67
  const pool = await (0, db_1.getPool)();
80
- const { rows } = await pool.query(GET_ALL_TOKENS);
68
+ const { rows } = await pool.query(sql_1.GET_ALL_ACTIVE_SERVICE_TOKENS);
81
69
  for (const row of rows) {
82
70
  if (await bcryptjs_1.default.compare(rawToken, row.token_hash)) {
83
- await pool.query(UPDATE_LAST_USED, [row.id]);
71
+ await pool.query(sql_1.UPDATE_SERVICE_TOKEN_LAST_USED, [row.id]);
84
72
  const { token_hash, ...record } = row;
85
73
  return record;
86
74
  }
@@ -92,7 +80,7 @@ async function validateServiceToken(rawToken) {
92
80
  */
93
81
  async function revokeServiceToken(id) {
94
82
  const pool = await (0, db_1.getPool)();
95
- const result = await pool.query(DELETE_TOKEN, [id]);
83
+ const result = await pool.query(sql_1.DELETE_SERVICE_TOKEN, [id]);
96
84
  return (result.rowCount ?? 0) > 0;
97
85
  }
98
86
  /**
@@ -100,6 +88,6 @@ async function revokeServiceToken(id) {
100
88
  */
101
89
  async function listServiceTokens(serverId) {
102
90
  const pool = await (0, db_1.getPool)();
103
- const { rows } = await pool.query(LIST_BY_SERVER, [serverId]);
91
+ const { rows } = await pool.query(sql_1.LIST_SERVICE_TOKENS_BY_SERVER, [serverId]);
104
92
  return rows;
105
93
  }
@@ -0,0 +1,11 @@
1
+ export declare const INSERT_BOT_KEY = "\n INSERT INTO lt_bot_api_keys (name, user_id, key_hash, scopes, expires_at)\n VALUES ($1, $2, $3, $4, $5) RETURNING id";
2
+ export declare const GET_BOT_KEYS_BY_USER = "\n SELECT id, name, user_id, key_hash, scopes\n FROM lt_bot_api_keys\n WHERE user_id = $1\n AND (expires_at IS NULL OR expires_at > NOW())";
3
+ export declare const GET_ALL_ACTIVE_BOT_KEYS = "\n SELECT id, name, user_id, key_hash, scopes\n FROM lt_bot_api_keys\n WHERE (expires_at IS NULL OR expires_at > NOW())";
4
+ export declare const UPDATE_BOT_KEY_LAST_USED = "\n UPDATE lt_bot_api_keys SET last_used_at = NOW() WHERE id = $1";
5
+ export declare const DELETE_BOT_KEY = "\n DELETE FROM lt_bot_api_keys WHERE id = $1";
6
+ export declare const LIST_BOT_KEYS_BY_USER = "\n SELECT id, name, user_id, scopes, expires_at, last_used_at, created_at, updated_at\n FROM lt_bot_api_keys WHERE user_id = $1 ORDER BY created_at";
7
+ export declare const INSERT_SERVICE_TOKEN = "\n INSERT INTO lt_service_tokens (name, token_hash, server_id, scopes, expires_at)\n VALUES ($1, $2, $3, $4, $5) RETURNING id";
8
+ export declare const GET_ALL_ACTIVE_SERVICE_TOKENS = "\n SELECT id, name, token_hash FROM lt_service_tokens\n WHERE (expires_at IS NULL OR expires_at > NOW())";
9
+ export declare const UPDATE_SERVICE_TOKEN_LAST_USED = "\n UPDATE lt_service_tokens SET last_used_at = NOW() WHERE id = $1";
10
+ export declare const DELETE_SERVICE_TOKEN = "\n DELETE FROM lt_service_tokens WHERE id = $1";
11
+ export declare const LIST_SERVICE_TOKENS_BY_SERVER = "\n SELECT id, name, server_id, scopes, expires_at, last_used_at, created_at, updated_at\n FROM lt_service_tokens WHERE server_id = $1 ORDER BY created_at";
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ // ── Bot API key queries ─────────────────────────────────────────────────────
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.LIST_SERVICE_TOKENS_BY_SERVER = exports.DELETE_SERVICE_TOKEN = exports.UPDATE_SERVICE_TOKEN_LAST_USED = exports.GET_ALL_ACTIVE_SERVICE_TOKENS = exports.INSERT_SERVICE_TOKEN = exports.LIST_BOT_KEYS_BY_USER = exports.DELETE_BOT_KEY = exports.UPDATE_BOT_KEY_LAST_USED = exports.GET_ALL_ACTIVE_BOT_KEYS = exports.GET_BOT_KEYS_BY_USER = exports.INSERT_BOT_KEY = void 0;
5
+ exports.INSERT_BOT_KEY = `
6
+ INSERT INTO lt_bot_api_keys (name, user_id, key_hash, scopes, expires_at)
7
+ VALUES ($1, $2, $3, $4, $5) RETURNING id`;
8
+ exports.GET_BOT_KEYS_BY_USER = `
9
+ SELECT id, name, user_id, key_hash, scopes
10
+ FROM lt_bot_api_keys
11
+ WHERE user_id = $1
12
+ AND (expires_at IS NULL OR expires_at > NOW())`;
13
+ exports.GET_ALL_ACTIVE_BOT_KEYS = `
14
+ SELECT id, name, user_id, key_hash, scopes
15
+ FROM lt_bot_api_keys
16
+ WHERE (expires_at IS NULL OR expires_at > NOW())`;
17
+ exports.UPDATE_BOT_KEY_LAST_USED = `
18
+ UPDATE lt_bot_api_keys SET last_used_at = NOW() WHERE id = $1`;
19
+ exports.DELETE_BOT_KEY = `
20
+ DELETE FROM lt_bot_api_keys WHERE id = $1`;
21
+ exports.LIST_BOT_KEYS_BY_USER = `
22
+ SELECT id, name, user_id, scopes, expires_at, last_used_at, created_at, updated_at
23
+ FROM lt_bot_api_keys WHERE user_id = $1 ORDER BY created_at`;
24
+ // ── Service token queries ───────────────────────────────────────────────────
25
+ exports.INSERT_SERVICE_TOKEN = `
26
+ INSERT INTO lt_service_tokens (name, token_hash, server_id, scopes, expires_at)
27
+ VALUES ($1, $2, $3, $4, $5) RETURNING id`;
28
+ exports.GET_ALL_ACTIVE_SERVICE_TOKENS = `
29
+ SELECT id, name, token_hash FROM lt_service_tokens
30
+ WHERE (expires_at IS NULL OR expires_at > NOW())`;
31
+ exports.UPDATE_SERVICE_TOKEN_LAST_USED = `
32
+ UPDATE lt_service_tokens SET last_used_at = NOW() WHERE id = $1`;
33
+ exports.DELETE_SERVICE_TOKEN = `
34
+ DELETE FROM lt_service_tokens WHERE id = $1`;
35
+ exports.LIST_SERVICE_TOKENS_BY_SERVER = `
36
+ SELECT id, name, server_id, scopes, expires_at, last_used_at, created_at, updated_at
37
+ FROM lt_service_tokens WHERE server_id = $1 ORDER BY created_at`;
@@ -61,8 +61,24 @@ export declare function findByMetadata(key: string, value: string, status?: stri
61
61
  export declare function claimByMetadata(key: string, value: string, userId: string, durationMinutes?: number, metadata?: Record<string, any>, allowedRoles?: string[] | null): Promise<(ClaimResult & {
62
62
  candidatesExist: number;
63
63
  }) | null>;
64
+ export interface ResolveByMetadataResult {
65
+ /** 'resolved' = done atomically. 'signal_required' = signal_id present, caller must signal. */
66
+ outcome: 'resolved' | 'signal_required' | 'not_found';
67
+ /** The resolved escalation (when outcome = 'resolved') */
68
+ escalation?: LTEscalationRecord;
69
+ /** Signal info (when outcome = 'signal_required') */
70
+ signalId?: string;
71
+ escalationId?: string;
72
+ workflowId?: string;
73
+ workflowType?: string;
74
+ taskQueue?: string;
75
+ }
64
76
  /**
65
- * Atomic resolve by metadata: find + claim + resolve in one CTE.
66
- * RBAC is enforced in the SQL WHERE clause via allowedRoles.
77
+ * Atomic resolve by metadata with signal guard.
78
+ *
79
+ * Single query, two outcomes:
80
+ * 1. No signal_id → claim + resolve atomically. Returns { outcome: 'resolved', escalation }.
81
+ * 2. signal_id present → resolve skipped. Returns { outcome: 'signal_required', signalId, escalationId, ... }
82
+ * so the caller can signal the workflow. conditionLT handles the rest.
67
83
  */
68
- export declare function resolveByMetadataAtomic(key: string, value: string, userId: string, resolverPayload: Record<string, any>, metadata?: Record<string, any>, allowedRoles?: string[] | null): Promise<LTEscalationRecord | null>;
84
+ export declare function resolveByMetadataAtomic(key: string, value: string, userId: string, resolverPayload: Record<string, any>, metadata?: Record<string, any>, allowedRoles?: string[] | null): Promise<ResolveByMetadataResult>;
@@ -249,8 +249,12 @@ async function claimByMetadata(key, value, userId, durationMinutes = 30, metadat
249
249
  };
250
250
  }
251
251
  /**
252
- * Atomic resolve by metadata: find + claim + resolve in one CTE.
253
- * RBAC is enforced in the SQL WHERE clause via allowedRoles.
252
+ * Atomic resolve by metadata with signal guard.
253
+ *
254
+ * Single query, two outcomes:
255
+ * 1. No signal_id → claim + resolve atomically. Returns { outcome: 'resolved', escalation }.
256
+ * 2. signal_id present → resolve skipped. Returns { outcome: 'signal_required', signalId, escalationId, ... }
257
+ * so the caller can signal the workflow. conditionLT handles the rest.
254
258
  */
255
259
  async function resolveByMetadataAtomic(key, value, userId, resolverPayload, metadata, allowedRoles) {
256
260
  const pool = (0, db_1.getPool)();
@@ -260,17 +264,30 @@ async function resolveByMetadataAtomic(key, value, userId, resolverPayload, meta
260
264
  const roles = allowedRoles ?? null;
261
265
  const { rows } = await pool.query(sql_1.RESOLVE_BY_METADATA_ATOMIC, [filter, userId, payloadJson, metaPatch, roles]);
262
266
  if (rows.length === 0)
263
- return null;
264
- const escalation = rows[0];
265
- (0, publish_1.publishEscalationEvent)({
266
- type: 'escalation.resolved',
267
- source: 'service',
268
- workflowId: escalation.workflow_id || '',
269
- workflowName: escalation.workflow_type || '',
270
- taskQueue: escalation.task_queue || '',
271
- escalationId: escalation.id,
272
- status: 'resolved',
273
- data: { resolved_by: userId },
274
- });
275
- return escalation;
267
+ return { outcome: 'not_found' };
268
+ const row = rows[0];
269
+ if (row.outcome === 'resolved') {
270
+ const { target_id, signal_id, target_workflow_id, target_workflow_type, target_task_queue, outcome, ...rest } = row;
271
+ const escalation = rest;
272
+ (0, publish_1.publishEscalationEvent)({
273
+ type: 'escalation.resolved',
274
+ source: 'service',
275
+ workflowId: escalation.workflow_id || '',
276
+ workflowName: escalation.workflow_type || '',
277
+ taskQueue: escalation.task_queue || '',
278
+ escalationId: escalation.id,
279
+ status: 'resolved',
280
+ data: { resolved_by: userId },
281
+ });
282
+ return { outcome: 'resolved', escalation };
283
+ }
284
+ // Signal-backed escalation — return the signal info for the caller
285
+ return {
286
+ outcome: 'signal_required',
287
+ signalId: row.signal_id,
288
+ escalationId: row.target_id,
289
+ workflowId: row.target_workflow_id,
290
+ workflowType: row.target_workflow_type,
291
+ taskQueue: row.target_task_queue,
292
+ };
276
293
  }
@@ -28,8 +28,15 @@ export declare const FIND_BY_METADATA = "SELECT *, COUNT(*) OVER() AS _total\nFR
28
28
  */
29
29
  export declare const CLAIM_BY_METADATA_GUARDED = "WITH target AS (\n SELECT id, assigned_to\n FROM lt_escalations\n WHERE metadata @> $1::jsonb\n AND status = 'pending'\n AND (assigned_to IS NULL OR assigned_until <= NOW() OR assigned_to = $2)\n AND ($5::text[] IS NULL OR role = ANY($5))\n ORDER BY priority ASC, created_at ASC\n LIMIT 1\n FOR UPDATE SKIP LOCKED\n),\nupdated AS (\n UPDATE lt_escalations e\n SET assigned_to = $2,\n claimed_at = NOW(),\n assigned_until = NOW() + INTERVAL '1 minute' * $3,\n metadata = CASE WHEN $4::jsonb IS NOT NULL\n THEN COALESCE(e.metadata, '{}'::jsonb) || $4::jsonb\n ELSE e.metadata END,\n updated_at = NOW()\n FROM target t\n WHERE e.id = t.id\n RETURNING e.*, t.assigned_to AS prev_assigned_to\n)\nSELECT *,\n (SELECT COUNT(*) FROM lt_escalations WHERE metadata @> $1::jsonb AND status = 'pending') AS candidates_exist\nFROM updated";
30
30
  /**
31
- * Atomic resolve by metadata: find + claim + resolve in one CTE.
31
+ * Atomic resolve by metadata with signal guard.
32
+ *
33
+ * Single query, two outcomes:
34
+ * 1. No signal_id → claim + resolve atomically. `resolved` is populated.
35
+ * 2. signal_id present → resolve CTE skips (guard in WHERE). `resolved` is null,
36
+ * but `target_id`, `signal_id`, `workflow_id`, `task_queue`, `workflow_type`
37
+ * are returned so the caller can signal the workflow directly.
38
+ *
32
39
  * $1 = metadata filter (jsonb), $2 = userId, $3 = resolver_payload (jsonb),
33
40
  * $4 = metadata patch (jsonb, nullable), $5 = allowed roles (text[], null = no filter)
34
41
  */
35
- export declare const RESOLVE_BY_METADATA_ATOMIC = "WITH target AS (\n SELECT id\n FROM lt_escalations\n WHERE metadata @> $1::jsonb\n AND status = 'pending'\n AND ($5::text[] IS NULL OR role = ANY($5))\n ORDER BY priority ASC, created_at ASC\n LIMIT 1\n FOR UPDATE SKIP LOCKED\n),\nclaimed AS (\n UPDATE lt_escalations e\n SET assigned_to = $2,\n claimed_at = NOW(),\n assigned_until = NOW() + INTERVAL '5 minutes',\n metadata = CASE WHEN $4::jsonb IS NOT NULL\n THEN COALESCE(e.metadata, '{}'::jsonb) || $4::jsonb\n ELSE e.metadata END\n FROM target\n WHERE e.id = target.id\n RETURNING e.*\n)\nUPDATE lt_escalations e\nSET status = 'resolved',\n resolved_at = NOW(),\n resolver_payload = $3,\n updated_at = NOW()\nFROM claimed\nWHERE e.id = claimed.id\nRETURNING e.*";
42
+ export declare const RESOLVE_BY_METADATA_ATOMIC = "WITH target AS (\n SELECT *\n FROM lt_escalations\n WHERE metadata @> $1::jsonb\n AND status = 'pending'\n AND ($5::text[] IS NULL OR role = ANY($5))\n ORDER BY priority ASC, created_at ASC\n LIMIT 1\n FOR UPDATE\n),\nclaimed AS (\n UPDATE lt_escalations e\n SET assigned_to = COALESCE(e.assigned_to, $2),\n claimed_at = COALESCE(e.claimed_at, NOW()),\n assigned_until = CASE\n WHEN e.assigned_to IS NOT NULL AND e.assigned_until > NOW() THEN e.assigned_until\n ELSE NOW() + INTERVAL '5 minutes' END,\n metadata = CASE WHEN $4::jsonb IS NOT NULL\n THEN COALESCE(e.metadata, '{}'::jsonb) || $4::jsonb\n ELSE e.metadata END\n FROM target\n WHERE e.id = target.id\n AND (target.metadata->>'signal_id') IS NULL\n RETURNING e.*\n),\nresolved AS (\n UPDATE lt_escalations e\n SET status = 'resolved',\n resolved_at = NOW(),\n resolver_payload = $3,\n updated_at = NOW()\n FROM claimed\n WHERE e.id = claimed.id\n RETURNING e.*\n)\nSELECT\n resolved.*,\n target.id AS target_id,\n target.metadata->>'signal_id' AS signal_id,\n target.workflow_id AS target_workflow_id,\n target.workflow_type AS target_workflow_type,\n target.task_queue AS target_task_queue,\n CASE WHEN resolved.id IS NOT NULL THEN 'resolved' ELSE 'signal_required' END AS outcome\nFROM target\nLEFT JOIN resolved ON resolved.id = target.id";