@hotmeshio/long-tail 0.4.21 → 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.
- package/build/api/escalations/claim.d.ts +2 -0
- package/build/api/escalations/claim.js +10 -6
- package/build/api/escalations/helpers.d.ts +24 -1
- package/build/api/escalations/helpers.js +48 -3
- package/build/api/escalations/metadata.d.ts +10 -2
- package/build/api/escalations/metadata.js +29 -6
- package/build/modules/auth.d.ts +1 -1
- package/build/modules/auth.js +3 -3
- package/build/routes/escalations/metadata.js +2 -1
- package/build/routes/escalations/single.js +1 -1
- package/build/sdk/index.d.ts +2 -0
- package/build/services/auth/bot-api-key.js +6 -24
- package/build/services/auth/service-token.js +6 -18
- package/build/services/auth/sql.d.ts +11 -0
- package/build/services/auth/sql.js +37 -0
- package/build/services/escalation/crud.d.ts +19 -3
- package/build/services/escalation/crud.js +32 -15
- package/build/services/escalation/sql.d.ts +9 -2
- package/build/services/escalation/sql.js +36 -14
- package/build/system/seed/tool-manifests-admin.d.ts +59 -0
- package/build/system/seed/tool-manifests-admin.js +2 -2
- package/build/tsconfig.tsbuildinfo +1 -1
- package/docs/api/http/escalations.md +38 -2
- package/docs/api/sdk/escalations.md +25 -5
- package/package.json +1 -1
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
33
|
-
*
|
|
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
|
|
103
|
-
*
|
|
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
|
|
119
|
-
if (
|
|
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
|
-
|
|
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
|
}
|
package/build/modules/auth.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/build/modules/auth.js
CHANGED
|
@@ -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
|
|
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
|
|
238
|
-
if (req.auth.role === '
|
|
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
|
/**
|
package/build/sdk/index.d.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
66
|
-
*
|
|
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<
|
|
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
|
|
253
|
-
*
|
|
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
|
|
264
|
-
const
|
|
265
|
-
(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
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
|
|
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";
|