@harborclient/team-hub 0.4.2 → 0.4.4
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/dist/cli.js +1847 -118
- package/dist/cli.js.map +1 -1
- package/package.json +3 -1
package/dist/cli.js
CHANGED
|
@@ -21,9 +21,22 @@ import { Command as Command2 } from "commander";
|
|
|
21
21
|
|
|
22
22
|
// src/config/serverConfig.ts
|
|
23
23
|
import { existsSync, readFileSync } from "fs";
|
|
24
|
-
import
|
|
24
|
+
import path2 from "path";
|
|
25
25
|
import { parse as parseYaml } from "yaml";
|
|
26
26
|
|
|
27
|
+
// src/config/docsConfig.ts
|
|
28
|
+
import path from "path";
|
|
29
|
+
var DEFAULT_DOCS_SEARCH_INDEX_PATH = "/app/data/docsSearchIndex.json";
|
|
30
|
+
function resolveDocsSearchIndexPath(searchIndexPath) {
|
|
31
|
+
return path.isAbsolute(searchIndexPath) ? searchIndexPath : path.resolve(process.cwd(), searchIndexPath);
|
|
32
|
+
}
|
|
33
|
+
function normalizeDocsConfig(section) {
|
|
34
|
+
const trimmed = section.searchIndexPath?.trim();
|
|
35
|
+
return {
|
|
36
|
+
searchIndexPath: trimmed && trimmed.length > 0 ? trimmed : DEFAULT_DOCS_SEARCH_INDEX_PATH
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
// src/config/llmConfig.ts
|
|
28
41
|
function headerRowsFromSingleKeyObject(record) {
|
|
29
42
|
const rows = [];
|
|
@@ -176,6 +189,9 @@ var pluginsSectionSchema = z.object({
|
|
|
176
189
|
catalogs: z.array(z.string().trim().url()).optional(),
|
|
177
190
|
trusted: z.array(z.string().trim().url()).optional()
|
|
178
191
|
});
|
|
192
|
+
var docsSectionSchema = z.object({
|
|
193
|
+
searchIndexPath: z.string().trim().min(1).optional()
|
|
194
|
+
});
|
|
179
195
|
var logLevelSchema = z.enum(["debug", "info", "warn", "error"]);
|
|
180
196
|
var loggingSectionSchema = z.object({
|
|
181
197
|
level: logLevelSchema.optional(),
|
|
@@ -188,6 +204,7 @@ var serverConfigDocumentSchema = z.object({
|
|
|
188
204
|
redis: redisSectionSchema,
|
|
189
205
|
llm: llmSectionSchema.optional(),
|
|
190
206
|
plugins: pluginsSectionSchema.optional(),
|
|
207
|
+
docs: docsSectionSchema.optional(),
|
|
191
208
|
logging: loggingSectionSchema.optional()
|
|
192
209
|
});
|
|
193
210
|
|
|
@@ -205,7 +222,7 @@ var ConfigError = class extends Error {
|
|
|
205
222
|
}
|
|
206
223
|
};
|
|
207
224
|
function resolveConfigPath(configPath) {
|
|
208
|
-
return
|
|
225
|
+
return path2.isAbsolute(configPath) ? configPath : path2.resolve(process.cwd(), configPath);
|
|
209
226
|
}
|
|
210
227
|
function assertDocumentShape(document) {
|
|
211
228
|
if (document === null || typeof document !== "object" || Array.isArray(document)) {
|
|
@@ -288,6 +305,14 @@ function parseServerConfig(document) {
|
|
|
288
305
|
}
|
|
289
306
|
plugins = normalizePluginsConfig(parsedPluginsSection.data);
|
|
290
307
|
}
|
|
308
|
+
let docs = null;
|
|
309
|
+
if (root.docs !== void 0) {
|
|
310
|
+
const parsedDocsSection = docsSectionSchema.safeParse(root.docs);
|
|
311
|
+
if (!parsedDocsSection.success) {
|
|
312
|
+
throw new ConfigError(formatZodError(parsedDocsSection.error));
|
|
313
|
+
}
|
|
314
|
+
docs = normalizeDocsConfig(parsedDocsSection.data);
|
|
315
|
+
}
|
|
291
316
|
let logging = DEFAULT_LOGGING_CONFIG;
|
|
292
317
|
if (root.logging !== void 0) {
|
|
293
318
|
const parsedLoggingSection = loggingSectionSchema.safeParse(root.logging);
|
|
@@ -303,6 +328,7 @@ function parseServerConfig(document) {
|
|
|
303
328
|
redis: parsedRedisSection.data,
|
|
304
329
|
llm,
|
|
305
330
|
plugins,
|
|
331
|
+
docs,
|
|
306
332
|
logging
|
|
307
333
|
};
|
|
308
334
|
}
|
|
@@ -340,7 +366,7 @@ function mergeGlobalOptions(command, options) {
|
|
|
340
366
|
}
|
|
341
367
|
|
|
342
368
|
// src/db/firestore/FirestoreDatabase.ts
|
|
343
|
-
import { randomUUID as
|
|
369
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
344
370
|
import { Firestore } from "@google-cloud/firestore";
|
|
345
371
|
|
|
346
372
|
// src/db/attribution.ts
|
|
@@ -353,9 +379,26 @@ async function resolveActingUserName(findUserById, actingUserId) {
|
|
|
353
379
|
import { randomUUID } from "crypto";
|
|
354
380
|
var BOOTSTRAP_USER_NAME = "bootstrap";
|
|
355
381
|
|
|
382
|
+
// src/db/invitationErrors.ts
|
|
383
|
+
var InvitationUnavailableError = class extends Error {
|
|
384
|
+
/**
|
|
385
|
+
* Specific failure reason used to choose a safe HTTP status code.
|
|
386
|
+
*/
|
|
387
|
+
reason;
|
|
388
|
+
/**
|
|
389
|
+
* @param reason - Why the invitation cannot be used.
|
|
390
|
+
*/
|
|
391
|
+
constructor(reason) {
|
|
392
|
+
super("Invalid or expired invitation.");
|
|
393
|
+
this.name = "InvitationUnavailableError";
|
|
394
|
+
this.reason = reason;
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
|
|
356
398
|
// src/db/firestore/const.ts
|
|
357
399
|
var USERS_COLLECTION = "users";
|
|
358
400
|
var API_TOKENS_COLLECTION = "apiTokens";
|
|
401
|
+
var INVITATIONS_COLLECTION = "invitations";
|
|
359
402
|
var COLLECTIONS_COLLECTION = "collections";
|
|
360
403
|
var ENVIRONMENTS_COLLECTION = "environments";
|
|
361
404
|
var SNIPPETS_COLLECTION = "snippets";
|
|
@@ -401,11 +444,25 @@ var firestoreConfigSchema = z2.object({
|
|
|
401
444
|
|
|
402
445
|
// src/db/firestore/utils.ts
|
|
403
446
|
function parseAuditEntityType(value) {
|
|
404
|
-
if (value === "user" || value === "api_token" || value === "collection" || value === "environment" || value === "snippet" || value === "folder" || value === "request" || value === "run_result") {
|
|
447
|
+
if (value === "user" || value === "api_token" || value === "invitation" || value === "collection" || value === "environment" || value === "snippet" || value === "folder" || value === "request" || value === "run_result") {
|
|
405
448
|
return value;
|
|
406
449
|
}
|
|
407
450
|
throw new Error(`Invalid audit entity type: ${value}`);
|
|
408
451
|
}
|
|
452
|
+
function mapFirestoreInvitation(id, data) {
|
|
453
|
+
return {
|
|
454
|
+
id,
|
|
455
|
+
userId: data.userId,
|
|
456
|
+
codeHash: data.codeHash,
|
|
457
|
+
codePrefix: data.codePrefix,
|
|
458
|
+
expiresAt: data.expiresAt,
|
|
459
|
+
redeemedAt: data.redeemedAt,
|
|
460
|
+
revokedAt: data.revokedAt,
|
|
461
|
+
createdAt: data.createdAt,
|
|
462
|
+
createdByUserId: data.createdByUserId ?? null,
|
|
463
|
+
updatedByUserId: data.updatedByUserId ?? null
|
|
464
|
+
};
|
|
465
|
+
}
|
|
409
466
|
function mapFirestoreApiToken(id, data) {
|
|
410
467
|
if (!data.userId) {
|
|
411
468
|
throw new Error(`API token ${id} is missing a userId`);
|
|
@@ -618,6 +675,39 @@ function parseRunResultPayload(payload) {
|
|
|
618
675
|
};
|
|
619
676
|
}
|
|
620
677
|
|
|
678
|
+
// src/server/auth/apiTokens.ts
|
|
679
|
+
import { createHash, randomBytes, randomUUID as randomUUID2 } from "crypto";
|
|
680
|
+
var TOKEN_PREFIX = "hbk_";
|
|
681
|
+
function hashToken(token) {
|
|
682
|
+
return createHash("sha256").update(token).digest("hex");
|
|
683
|
+
}
|
|
684
|
+
function generateApiToken(userId, name) {
|
|
685
|
+
const secretSuffix = randomBytes(32).toString("base64url");
|
|
686
|
+
const secret = `${TOKEN_PREFIX}${secretSuffix}`;
|
|
687
|
+
const tokenPrefix = `${TOKEN_PREFIX}${secretSuffix.slice(0, 8)}`;
|
|
688
|
+
const createdAt = /* @__PURE__ */ new Date();
|
|
689
|
+
const record = {
|
|
690
|
+
id: randomUUID2(),
|
|
691
|
+
userId,
|
|
692
|
+
name,
|
|
693
|
+
tokenHash: hashToken(secret),
|
|
694
|
+
tokenPrefix,
|
|
695
|
+
createdAt,
|
|
696
|
+
lastUsedAt: null,
|
|
697
|
+
revokedAt: null,
|
|
698
|
+
createdByUserId: null,
|
|
699
|
+
updatedByUserId: null
|
|
700
|
+
};
|
|
701
|
+
return { record, secret };
|
|
702
|
+
}
|
|
703
|
+
function extractBearer(headerValue) {
|
|
704
|
+
if (!headerValue) {
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
const match = /^Bearer\s+(\S+)$/i.exec(headerValue.trim());
|
|
708
|
+
return match?.[1] ?? null;
|
|
709
|
+
}
|
|
710
|
+
|
|
621
711
|
// src/db/trimRequiredName.ts
|
|
622
712
|
function trimRequiredName(name, label) {
|
|
623
713
|
const trimmed = name.trim();
|
|
@@ -816,7 +906,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
816
906
|
async createUser(input, actingUserId) {
|
|
817
907
|
const trimmedName = trimRequiredName(input.name, "User name");
|
|
818
908
|
assertUserNameNotReserved(trimmedName);
|
|
819
|
-
const id =
|
|
909
|
+
const id = randomUUID3();
|
|
820
910
|
const now = /* @__PURE__ */ new Date();
|
|
821
911
|
const attributionUserId = trimmedName === SYSTEM_USER_NAME ? id : actingUserId;
|
|
822
912
|
const data = {
|
|
@@ -1120,6 +1210,205 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
1120
1210
|
async touchApiTokenLastUsed(id, when) {
|
|
1121
1211
|
await this.requireClient().collection(API_TOKENS_COLLECTION).doc(id).update({ lastUsedAt: when });
|
|
1122
1212
|
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Creates a user account and its initial onboarding invitation in one transaction.
|
|
1215
|
+
*
|
|
1216
|
+
* @param userId - Pre-generated stable identifier for the new user.
|
|
1217
|
+
* @param input - User fields to persist.
|
|
1218
|
+
* @param invitation - Invitation metadata including the stored code hash.
|
|
1219
|
+
* @param actingUserId - User performing the create action.
|
|
1220
|
+
*/
|
|
1221
|
+
async createInvitedUser(userId, input, invitation, actingUserId) {
|
|
1222
|
+
const trimmedName = trimRequiredName(input.name, "User name");
|
|
1223
|
+
assertUserNameNotReserved(trimmedName);
|
|
1224
|
+
const now = /* @__PURE__ */ new Date();
|
|
1225
|
+
const client = this.requireClient();
|
|
1226
|
+
const userRef = client.collection(USERS_COLLECTION).doc(userId);
|
|
1227
|
+
const invitationRef = client.collection(INVITATIONS_COLLECTION).doc(invitation.id);
|
|
1228
|
+
const userData = {
|
|
1229
|
+
name: trimmedName,
|
|
1230
|
+
role: input.role,
|
|
1231
|
+
collectionAccess: input.collectionAccess,
|
|
1232
|
+
environmentAccess: input.environmentAccess,
|
|
1233
|
+
snippetAccess: input.snippetAccess,
|
|
1234
|
+
llmAccess: input.llmAccess ?? false,
|
|
1235
|
+
llmModels: input.llmModels ?? [],
|
|
1236
|
+
llmMonthlyTokenLimit: input.llmMonthlyTokenLimit ?? null,
|
|
1237
|
+
createdAt: now,
|
|
1238
|
+
updatedAt: now,
|
|
1239
|
+
createdByUserId: actingUserId,
|
|
1240
|
+
updatedByUserId: actingUserId
|
|
1241
|
+
};
|
|
1242
|
+
const invitationData = {
|
|
1243
|
+
userId: invitation.userId,
|
|
1244
|
+
codeHash: invitation.codeHash,
|
|
1245
|
+
codePrefix: invitation.codePrefix,
|
|
1246
|
+
expiresAt: invitation.expiresAt,
|
|
1247
|
+
redeemedAt: invitation.redeemedAt,
|
|
1248
|
+
revokedAt: invitation.revokedAt,
|
|
1249
|
+
createdAt: invitation.createdAt,
|
|
1250
|
+
createdByUserId: actingUserId,
|
|
1251
|
+
updatedByUserId: actingUserId
|
|
1252
|
+
};
|
|
1253
|
+
await client.runTransaction(async (transaction) => {
|
|
1254
|
+
transaction.set(userRef, userData);
|
|
1255
|
+
transaction.set(invitationRef, invitationData);
|
|
1256
|
+
});
|
|
1257
|
+
await this.recordAuditEntry(actingUserId, "create", "user", userId);
|
|
1258
|
+
await this.recordAuditEntry(actingUserId, "create", "invitation", invitation.id);
|
|
1259
|
+
return {
|
|
1260
|
+
user: mapFirestoreUser(userId, userData),
|
|
1261
|
+
invitation
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Persists a new onboarding invitation for an existing user account.
|
|
1266
|
+
*
|
|
1267
|
+
* @param invitation - Invitation metadata including the stored code hash.
|
|
1268
|
+
* @param actingUserId - User performing the create action.
|
|
1269
|
+
*/
|
|
1270
|
+
async createInvitation(invitation, actingUserId) {
|
|
1271
|
+
const user = await this.findUserById(invitation.userId);
|
|
1272
|
+
if (!user) {
|
|
1273
|
+
throw new Error("User not found");
|
|
1274
|
+
}
|
|
1275
|
+
const data = {
|
|
1276
|
+
userId: invitation.userId,
|
|
1277
|
+
codeHash: invitation.codeHash,
|
|
1278
|
+
codePrefix: invitation.codePrefix,
|
|
1279
|
+
expiresAt: invitation.expiresAt,
|
|
1280
|
+
redeemedAt: invitation.redeemedAt,
|
|
1281
|
+
revokedAt: invitation.revokedAt,
|
|
1282
|
+
createdAt: invitation.createdAt,
|
|
1283
|
+
createdByUserId: actingUserId,
|
|
1284
|
+
updatedByUserId: actingUserId
|
|
1285
|
+
};
|
|
1286
|
+
await this.requireClient().collection(INVITATIONS_COLLECTION).doc(invitation.id).set(data);
|
|
1287
|
+
await this.recordAuditEntry(actingUserId, "create", "invitation", invitation.id);
|
|
1288
|
+
return invitation;
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Finds an invitation by stable identifier.
|
|
1292
|
+
*
|
|
1293
|
+
* @param id - Invitation identifier to look up.
|
|
1294
|
+
*/
|
|
1295
|
+
async findInvitationById(id) {
|
|
1296
|
+
const snapshot = await this.requireClient().collection(INVITATIONS_COLLECTION).doc(id).get();
|
|
1297
|
+
if (!snapshot.exists) {
|
|
1298
|
+
return null;
|
|
1299
|
+
}
|
|
1300
|
+
return mapFirestoreInvitation(snapshot.id, snapshot.data());
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Finds an invitation by the sha256 hash of its secret.
|
|
1304
|
+
*
|
|
1305
|
+
* @param codeHash - sha256 hex digest of the invitation secret.
|
|
1306
|
+
*/
|
|
1307
|
+
async findInvitationByCodeHash(codeHash) {
|
|
1308
|
+
const snapshot = await this.requireClient().collection(INVITATIONS_COLLECTION).where("codeHash", "==", codeHash).limit(1).get();
|
|
1309
|
+
const doc = snapshot.docs[0];
|
|
1310
|
+
if (!doc) {
|
|
1311
|
+
return null;
|
|
1312
|
+
}
|
|
1313
|
+
return mapFirestoreInvitation(doc.id, doc.data());
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Lists all invitations ordered by creation time descending.
|
|
1317
|
+
*/
|
|
1318
|
+
async listInvitations() {
|
|
1319
|
+
const snapshot = await this.requireClient().collection(INVITATIONS_COLLECTION).orderBy("createdAt", "desc").get();
|
|
1320
|
+
return snapshot.docs.map(
|
|
1321
|
+
(doc) => mapFirestoreInvitation(doc.id, doc.data())
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* Revokes a pending invitation by id.
|
|
1326
|
+
*
|
|
1327
|
+
* @param id - Invitation identifier to revoke.
|
|
1328
|
+
* @param actingUserId - User performing the revoke action.
|
|
1329
|
+
*/
|
|
1330
|
+
async revokeInvitation(id, actingUserId) {
|
|
1331
|
+
const docRef = this.requireClient().collection(INVITATIONS_COLLECTION).doc(id);
|
|
1332
|
+
const snapshot = await docRef.get();
|
|
1333
|
+
if (!snapshot.exists) {
|
|
1334
|
+
return false;
|
|
1335
|
+
}
|
|
1336
|
+
const data = snapshot.data();
|
|
1337
|
+
const now = /* @__PURE__ */ new Date();
|
|
1338
|
+
if (data.redeemedAt !== null || data.revokedAt !== null || data.expiresAt.getTime() <= now.getTime()) {
|
|
1339
|
+
return false;
|
|
1340
|
+
}
|
|
1341
|
+
await docRef.update({ revokedAt: now, updatedByUserId: actingUserId });
|
|
1342
|
+
await this.recordAuditEntry(actingUserId, "update", "invitation", id);
|
|
1343
|
+
return true;
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Atomically consumes a pending invitation and issues a permanent API token.
|
|
1347
|
+
*
|
|
1348
|
+
* @param codeHash - sha256 hex digest of the invitation secret.
|
|
1349
|
+
* @param tokenName - Label stored on the newly created API token.
|
|
1350
|
+
* @param actingUserId - Internal user attributed with the redemption action.
|
|
1351
|
+
*/
|
|
1352
|
+
async redeemInvitation(codeHash, tokenName, actingUserId) {
|
|
1353
|
+
const now = /* @__PURE__ */ new Date();
|
|
1354
|
+
const client = this.requireClient();
|
|
1355
|
+
const invitationQuery = client.collection(INVITATIONS_COLLECTION).where("codeHash", "==", codeHash).limit(1);
|
|
1356
|
+
let user;
|
|
1357
|
+
let invitation;
|
|
1358
|
+
let token;
|
|
1359
|
+
let secret;
|
|
1360
|
+
await client.runTransaction(async (transaction) => {
|
|
1361
|
+
const invitationSnapshot = await transaction.get(invitationQuery);
|
|
1362
|
+
const invitationDoc = invitationSnapshot.docs[0];
|
|
1363
|
+
if (!invitationDoc) {
|
|
1364
|
+
throw new InvitationUnavailableError("not_found");
|
|
1365
|
+
}
|
|
1366
|
+
const invitationData = invitationDoc.data();
|
|
1367
|
+
if (invitationData.redeemedAt) {
|
|
1368
|
+
throw new InvitationUnavailableError("redeemed");
|
|
1369
|
+
}
|
|
1370
|
+
if (invitationData.revokedAt) {
|
|
1371
|
+
throw new InvitationUnavailableError("revoked");
|
|
1372
|
+
}
|
|
1373
|
+
if (invitationData.expiresAt.getTime() <= now.getTime()) {
|
|
1374
|
+
throw new InvitationUnavailableError("expired");
|
|
1375
|
+
}
|
|
1376
|
+
transaction.update(invitationDoc.ref, {
|
|
1377
|
+
redeemedAt: now,
|
|
1378
|
+
updatedByUserId: actingUserId
|
|
1379
|
+
});
|
|
1380
|
+
const userRef = client.collection(USERS_COLLECTION).doc(invitationData.userId);
|
|
1381
|
+
const userSnapshot = await transaction.get(userRef);
|
|
1382
|
+
if (!userSnapshot.exists) {
|
|
1383
|
+
throw new Error("User not found");
|
|
1384
|
+
}
|
|
1385
|
+
user = mapFirestoreUser(userSnapshot.id, userSnapshot.data());
|
|
1386
|
+
invitation = mapFirestoreInvitation(invitationDoc.id, {
|
|
1387
|
+
...invitationData,
|
|
1388
|
+
redeemedAt: now,
|
|
1389
|
+
updatedByUserId: actingUserId
|
|
1390
|
+
});
|
|
1391
|
+
const generated = generateApiToken(user.id, tokenName);
|
|
1392
|
+
token = generated.record;
|
|
1393
|
+
secret = generated.secret;
|
|
1394
|
+
const tokenRef = client.collection(API_TOKENS_COLLECTION).doc(token.id);
|
|
1395
|
+
const tokenData = {
|
|
1396
|
+
userId: token.userId,
|
|
1397
|
+
name: token.name,
|
|
1398
|
+
tokenHash: token.tokenHash,
|
|
1399
|
+
tokenPrefix: token.tokenPrefix,
|
|
1400
|
+
createdAt: token.createdAt,
|
|
1401
|
+
lastUsedAt: token.lastUsedAt,
|
|
1402
|
+
revokedAt: token.revokedAt,
|
|
1403
|
+
createdByUserId: actingUserId,
|
|
1404
|
+
updatedByUserId: actingUserId
|
|
1405
|
+
};
|
|
1406
|
+
transaction.set(tokenRef, tokenData);
|
|
1407
|
+
});
|
|
1408
|
+
await this.recordAuditEntry(actingUserId, "update", "invitation", invitation.id);
|
|
1409
|
+
await this.recordAuditEntry(actingUserId, "create", "api_token", token.id);
|
|
1410
|
+
return { user, token, secret };
|
|
1411
|
+
}
|
|
1123
1412
|
/**
|
|
1124
1413
|
* Lists all collections ordered by name.
|
|
1125
1414
|
*/
|
|
@@ -1137,7 +1426,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
1137
1426
|
*/
|
|
1138
1427
|
async createCollection(name, actingUserId) {
|
|
1139
1428
|
const trimmedName = trimRequiredName(name, "Collection name");
|
|
1140
|
-
const id =
|
|
1429
|
+
const id = randomUUID3();
|
|
1141
1430
|
const now = /* @__PURE__ */ new Date();
|
|
1142
1431
|
const data = {
|
|
1143
1432
|
name: trimmedName,
|
|
@@ -1269,7 +1558,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
1269
1558
|
*/
|
|
1270
1559
|
async createEnvironment(name, actingUserId) {
|
|
1271
1560
|
const trimmedName = trimRequiredName(name, "Environment name");
|
|
1272
|
-
const id =
|
|
1561
|
+
const id = randomUUID3();
|
|
1273
1562
|
const now = /* @__PURE__ */ new Date();
|
|
1274
1563
|
const data = {
|
|
1275
1564
|
name: trimmedName,
|
|
@@ -1386,7 +1675,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
1386
1675
|
*/
|
|
1387
1676
|
async createSnippet(name, code, scope, actingUserId) {
|
|
1388
1677
|
const trimmedName = trimRequiredName(name, "Snippet name");
|
|
1389
|
-
const id =
|
|
1678
|
+
const id = randomUUID3();
|
|
1390
1679
|
const now = /* @__PURE__ */ new Date();
|
|
1391
1680
|
const existing = await this.listSnippets();
|
|
1392
1681
|
const maxOrder = existing.reduce((max, snippet) => Math.max(max, snippet.sortOrder), -1);
|
|
@@ -1580,7 +1869,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
1580
1869
|
}
|
|
1581
1870
|
const existingRequests = await this.listRequests(input.collectionId);
|
|
1582
1871
|
const maxOrder = existingRequests.filter((request) => request.folderId === folderId).reduce((max, request) => Math.max(max, request.sortOrder), -1);
|
|
1583
|
-
const id =
|
|
1872
|
+
const id = randomUUID3();
|
|
1584
1873
|
const data = {
|
|
1585
1874
|
collectionId: input.collectionId,
|
|
1586
1875
|
folderId,
|
|
@@ -1650,7 +1939,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
1650
1939
|
*/
|
|
1651
1940
|
async createFolder(collectionId, name, actingUserId) {
|
|
1652
1941
|
const trimmedName = trimRequiredName(name, "Folder name");
|
|
1653
|
-
const id =
|
|
1942
|
+
const id = randomUUID3();
|
|
1654
1943
|
const now = /* @__PURE__ */ new Date();
|
|
1655
1944
|
const existingFolders = await this.listFolders(collectionId);
|
|
1656
1945
|
const maxOrder = existingFolders.reduce((max, folder) => Math.max(max, folder.sortOrder), -1);
|
|
@@ -1912,7 +2201,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
1912
2201
|
async createRunResult(input, actingUserId) {
|
|
1913
2202
|
const metadata = parseRunResultPayload(input.payload);
|
|
1914
2203
|
const label = input.label?.trim() || buildDefaultRunResultLabel(metadata);
|
|
1915
|
-
const id =
|
|
2204
|
+
const id = randomUUID3();
|
|
1916
2205
|
const now = /* @__PURE__ */ new Date();
|
|
1917
2206
|
const data = {
|
|
1918
2207
|
kind: metadata.kind,
|
|
@@ -1961,7 +2250,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
1961
2250
|
* @param input - Usage details for one successful completion step.
|
|
1962
2251
|
*/
|
|
1963
2252
|
async createLlmUsageLog(input) {
|
|
1964
|
-
const id =
|
|
2253
|
+
const id = randomUUID3();
|
|
1965
2254
|
const now = /* @__PURE__ */ new Date();
|
|
1966
2255
|
const data = {
|
|
1967
2256
|
userId: input.userId,
|
|
@@ -2017,7 +2306,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
2017
2306
|
return;
|
|
2018
2307
|
}
|
|
2019
2308
|
const input = createSystemUserInput();
|
|
2020
|
-
const id =
|
|
2309
|
+
const id = randomUUID3();
|
|
2021
2310
|
const now = /* @__PURE__ */ new Date();
|
|
2022
2311
|
const trimmedName = trimRequiredName(input.name, "User name");
|
|
2023
2312
|
const data = {
|
|
@@ -2051,7 +2340,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
2051
2340
|
(userId) => this.findUserById(userId),
|
|
2052
2341
|
actingUserId
|
|
2053
2342
|
);
|
|
2054
|
-
const id =
|
|
2343
|
+
const id = randomUUID3();
|
|
2055
2344
|
const now = /* @__PURE__ */ new Date();
|
|
2056
2345
|
const data = {
|
|
2057
2346
|
userId: actingUserId,
|
|
@@ -2079,7 +2368,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
2079
2368
|
};
|
|
2080
2369
|
|
|
2081
2370
|
// src/db/mysql/MysqlDatabase.ts
|
|
2082
|
-
import { randomUUID as
|
|
2371
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
2083
2372
|
import mysql from "mysql2/promise";
|
|
2084
2373
|
|
|
2085
2374
|
// src/db/apiTokenRows.ts
|
|
@@ -2101,6 +2390,49 @@ function mapApiTokenSqlRow(row) {
|
|
|
2101
2390
|
};
|
|
2102
2391
|
}
|
|
2103
2392
|
|
|
2393
|
+
// src/db/invitationRows.ts
|
|
2394
|
+
function mapInvitationSqlRow(row) {
|
|
2395
|
+
return {
|
|
2396
|
+
id: row.id,
|
|
2397
|
+
userId: row.user_id,
|
|
2398
|
+
codeHash: row.code_hash,
|
|
2399
|
+
codePrefix: row.code_prefix,
|
|
2400
|
+
expiresAt: row.expires_at,
|
|
2401
|
+
redeemedAt: row.redeemed_at,
|
|
2402
|
+
revokedAt: row.revoked_at,
|
|
2403
|
+
createdAt: row.created_at,
|
|
2404
|
+
createdByUserId: row.created_by_user_id ?? null,
|
|
2405
|
+
updatedByUserId: row.updated_by_user_id ?? null
|
|
2406
|
+
};
|
|
2407
|
+
}
|
|
2408
|
+
function getInvitationStatus(invitation, now = /* @__PURE__ */ new Date()) {
|
|
2409
|
+
if (invitation.redeemedAt) {
|
|
2410
|
+
return "redeemed";
|
|
2411
|
+
}
|
|
2412
|
+
if (invitation.revokedAt) {
|
|
2413
|
+
return "revoked";
|
|
2414
|
+
}
|
|
2415
|
+
if (invitation.expiresAt.getTime() <= now.getTime()) {
|
|
2416
|
+
return "expired";
|
|
2417
|
+
}
|
|
2418
|
+
return "pending";
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
// src/db/mysql/invitationSql.ts
|
|
2422
|
+
var INVITATION_SELECT_COLUMNS = `
|
|
2423
|
+
id,
|
|
2424
|
+
user_id,
|
|
2425
|
+
code_hash,
|
|
2426
|
+
code_prefix,
|
|
2427
|
+
expires_at,
|
|
2428
|
+
redeemed_at,
|
|
2429
|
+
revoked_at,
|
|
2430
|
+
created_at,
|
|
2431
|
+
created_by_user_id,
|
|
2432
|
+
updated_by_user_id
|
|
2433
|
+
`.trim();
|
|
2434
|
+
var INVITATION_SELECT = `SELECT ${INVITATION_SELECT_COLUMNS} FROM user_invitations`;
|
|
2435
|
+
|
|
2104
2436
|
// src/db/auditLogRows.ts
|
|
2105
2437
|
function parseAuditAction(value) {
|
|
2106
2438
|
if (value === "create" || value === "update" || value === "delete" || value === "reorder" || value === "move") {
|
|
@@ -2109,7 +2441,7 @@ function parseAuditAction(value) {
|
|
|
2109
2441
|
throw new Error(`Invalid audit action: ${value}`);
|
|
2110
2442
|
}
|
|
2111
2443
|
function parseAuditEntityType2(value) {
|
|
2112
|
-
if (value === "user" || value === "api_token" || value === "collection" || value === "environment" || value === "snippet" || value === "folder" || value === "request" || value === "run_result") {
|
|
2444
|
+
if (value === "user" || value === "api_token" || value === "invitation" || value === "collection" || value === "environment" || value === "snippet" || value === "folder" || value === "request" || value === "run_result") {
|
|
2113
2445
|
return value;
|
|
2114
2446
|
}
|
|
2115
2447
|
throw new Error(`Invalid audit entity type: ${value}`);
|
|
@@ -2515,6 +2847,25 @@ CREATE TABLE IF NOT EXISTS run_results (
|
|
|
2515
2847
|
INDEX run_results_created_idx (created_at)
|
|
2516
2848
|
)
|
|
2517
2849
|
`.trim();
|
|
2850
|
+
var USER_INVITATIONS_MIGRATION_SQL = `
|
|
2851
|
+
CREATE TABLE IF NOT EXISTS user_invitations (
|
|
2852
|
+
id VARCHAR(36) PRIMARY KEY,
|
|
2853
|
+
user_id VARCHAR(36) NOT NULL,
|
|
2854
|
+
code_hash CHAR(64) NOT NULL UNIQUE,
|
|
2855
|
+
code_prefix VARCHAR(32) NOT NULL,
|
|
2856
|
+
expires_at DATETIME NOT NULL,
|
|
2857
|
+
redeemed_at DATETIME,
|
|
2858
|
+
revoked_at DATETIME,
|
|
2859
|
+
created_at DATETIME NOT NULL,
|
|
2860
|
+
created_by_user_id VARCHAR(36),
|
|
2861
|
+
updated_by_user_id VARCHAR(36),
|
|
2862
|
+
INDEX user_invitations_user_id_idx (user_id),
|
|
2863
|
+
INDEX user_invitations_expires_at_idx (expires_at),
|
|
2864
|
+
CONSTRAINT user_invitations_user_id_fk FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
2865
|
+
CONSTRAINT user_invitations_created_by_fk FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
|
2866
|
+
CONSTRAINT user_invitations_updated_by_fk FOREIGN KEY (updated_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
2867
|
+
)
|
|
2868
|
+
`.trim();
|
|
2518
2869
|
var MYSQL_MIGRATIONS = [
|
|
2519
2870
|
USERS_MIGRATION_SQL,
|
|
2520
2871
|
API_TOKENS_MIGRATION_SQL,
|
|
@@ -2541,7 +2892,8 @@ var MYSQL_MIGRATIONS = [
|
|
|
2541
2892
|
ENVIRONMENTS_DELETION_LOCKED_MIGRATION_SQL,
|
|
2542
2893
|
USERS_SNIPPET_ACCESS_MIGRATION_SQL,
|
|
2543
2894
|
USERS_SNIPPET_ACCESS_BACKFILL_SQL,
|
|
2544
|
-
RUN_RESULTS_MIGRATION_SQL
|
|
2895
|
+
RUN_RESULTS_MIGRATION_SQL,
|
|
2896
|
+
USER_INVITATIONS_MIGRATION_SQL
|
|
2545
2897
|
];
|
|
2546
2898
|
|
|
2547
2899
|
// src/db/mysql/schemas.ts
|
|
@@ -2774,7 +3126,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
2774
3126
|
async createUser(input, actingUserId) {
|
|
2775
3127
|
const trimmedName = trimRequiredName(input.name, "User name");
|
|
2776
3128
|
assertUserNameNotReserved(trimmedName);
|
|
2777
|
-
const id =
|
|
3129
|
+
const id = randomUUID4();
|
|
2778
3130
|
const now = /* @__PURE__ */ new Date();
|
|
2779
3131
|
const attributionUserId = trimmedName === SYSTEM_USER_NAME ? id : actingUserId;
|
|
2780
3132
|
await this.executeStatement(
|
|
@@ -3102,53 +3454,343 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
3102
3454
|
await this.executeStatement(`UPDATE api_tokens SET last_used_at = ? WHERE id = ?`, [when, id]);
|
|
3103
3455
|
}
|
|
3104
3456
|
/**
|
|
3105
|
-
*
|
|
3457
|
+
* Creates a user account and its initial onboarding invitation in one transaction.
|
|
3458
|
+
*
|
|
3459
|
+
* @param userId - Pre-generated stable identifier for the new user.
|
|
3460
|
+
* @param input - User fields to persist.
|
|
3461
|
+
* @param invitation - Invitation metadata including the stored code hash.
|
|
3462
|
+
* @param actingUserId - User performing the create action.
|
|
3106
3463
|
*/
|
|
3107
|
-
async
|
|
3108
|
-
const
|
|
3109
|
-
|
|
3110
|
-
);
|
|
3111
|
-
|
|
3464
|
+
async createInvitedUser(userId, input, invitation, actingUserId) {
|
|
3465
|
+
const trimmedName = trimRequiredName(input.name, "User name");
|
|
3466
|
+
assertUserNameNotReserved(trimmedName);
|
|
3467
|
+
const now = /* @__PURE__ */ new Date();
|
|
3468
|
+
const connection = await this.requirePool().getConnection();
|
|
3469
|
+
try {
|
|
3470
|
+
await connection.beginTransaction();
|
|
3471
|
+
await connection.execute(
|
|
3472
|
+
`INSERT INTO users (
|
|
3473
|
+
id,
|
|
3474
|
+
name,
|
|
3475
|
+
role,
|
|
3476
|
+
collection_access,
|
|
3477
|
+
environment_access,
|
|
3478
|
+
snippet_access,
|
|
3479
|
+
llm_access,
|
|
3480
|
+
llm_models,
|
|
3481
|
+
llm_monthly_token_limit,
|
|
3482
|
+
created_at,
|
|
3483
|
+
updated_at,
|
|
3484
|
+
created_by_user_id,
|
|
3485
|
+
updated_by_user_id
|
|
3486
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
3487
|
+
[
|
|
3488
|
+
userId,
|
|
3489
|
+
trimmedName,
|
|
3490
|
+
input.role,
|
|
3491
|
+
serializeAccessList(input.collectionAccess),
|
|
3492
|
+
serializeAccessList(input.environmentAccess),
|
|
3493
|
+
serializeAccessList(input.snippetAccess),
|
|
3494
|
+
input.llmAccess ? 1 : 0,
|
|
3495
|
+
serializeAccessList(input.llmModels ?? []),
|
|
3496
|
+
input.llmMonthlyTokenLimit ?? null,
|
|
3497
|
+
now,
|
|
3498
|
+
now,
|
|
3499
|
+
actingUserId,
|
|
3500
|
+
actingUserId
|
|
3501
|
+
]
|
|
3502
|
+
);
|
|
3503
|
+
const [userRows] = await connection.execute(
|
|
3504
|
+
`${USER_SELECT} WHERE id = ? LIMIT 1`,
|
|
3505
|
+
[userId]
|
|
3506
|
+
);
|
|
3507
|
+
const userRow = userRows[0];
|
|
3508
|
+
if (!userRow) {
|
|
3509
|
+
throw new Error("User not found after insert");
|
|
3510
|
+
}
|
|
3511
|
+
await connection.execute(
|
|
3512
|
+
`INSERT INTO user_invitations (
|
|
3513
|
+
id,
|
|
3514
|
+
user_id,
|
|
3515
|
+
code_hash,
|
|
3516
|
+
code_prefix,
|
|
3517
|
+
expires_at,
|
|
3518
|
+
redeemed_at,
|
|
3519
|
+
revoked_at,
|
|
3520
|
+
created_at,
|
|
3521
|
+
created_by_user_id,
|
|
3522
|
+
updated_by_user_id
|
|
3523
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
3524
|
+
[
|
|
3525
|
+
invitation.id,
|
|
3526
|
+
invitation.userId,
|
|
3527
|
+
invitation.codeHash,
|
|
3528
|
+
invitation.codePrefix,
|
|
3529
|
+
invitation.expiresAt,
|
|
3530
|
+
invitation.redeemedAt,
|
|
3531
|
+
invitation.revokedAt,
|
|
3532
|
+
invitation.createdAt,
|
|
3533
|
+
actingUserId,
|
|
3534
|
+
actingUserId
|
|
3535
|
+
]
|
|
3536
|
+
);
|
|
3537
|
+
await connection.commit();
|
|
3538
|
+
await this.recordAuditEntry(actingUserId, "create", "user", userId);
|
|
3539
|
+
await this.recordAuditEntry(actingUserId, "create", "invitation", invitation.id);
|
|
3540
|
+
return {
|
|
3541
|
+
user: mapUserSqlRow(userRow),
|
|
3542
|
+
invitation
|
|
3543
|
+
};
|
|
3544
|
+
} catch (error) {
|
|
3545
|
+
await connection.rollback();
|
|
3546
|
+
throw error;
|
|
3547
|
+
} finally {
|
|
3548
|
+
connection.release();
|
|
3549
|
+
}
|
|
3112
3550
|
}
|
|
3113
3551
|
/**
|
|
3114
|
-
*
|
|
3552
|
+
* Persists a new onboarding invitation for an existing user account.
|
|
3115
3553
|
*
|
|
3116
|
-
* @param
|
|
3554
|
+
* @param invitation - Invitation metadata including the stored code hash.
|
|
3117
3555
|
* @param actingUserId - User performing the create action.
|
|
3118
3556
|
*/
|
|
3119
|
-
async
|
|
3120
|
-
const
|
|
3121
|
-
|
|
3122
|
-
|
|
3557
|
+
async createInvitation(invitation, actingUserId) {
|
|
3558
|
+
const user = await this.findUserById(invitation.userId);
|
|
3559
|
+
if (!user) {
|
|
3560
|
+
throw new Error("User not found");
|
|
3561
|
+
}
|
|
3123
3562
|
await this.executeStatement(
|
|
3124
|
-
`INSERT INTO
|
|
3563
|
+
`INSERT INTO user_invitations (
|
|
3125
3564
|
id,
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3565
|
+
user_id,
|
|
3566
|
+
code_hash,
|
|
3567
|
+
code_prefix,
|
|
3568
|
+
expires_at,
|
|
3569
|
+
redeemed_at,
|
|
3570
|
+
revoked_at,
|
|
3132
3571
|
created_at,
|
|
3133
|
-
updated_at,
|
|
3134
3572
|
created_by_user_id,
|
|
3135
3573
|
updated_by_user_id
|
|
3136
|
-
) VALUES (?, ?,
|
|
3137
|
-
[
|
|
3574
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
3575
|
+
[
|
|
3576
|
+
invitation.id,
|
|
3577
|
+
invitation.userId,
|
|
3578
|
+
invitation.codeHash,
|
|
3579
|
+
invitation.codePrefix,
|
|
3580
|
+
invitation.expiresAt,
|
|
3581
|
+
invitation.redeemedAt,
|
|
3582
|
+
invitation.revokedAt,
|
|
3583
|
+
invitation.createdAt,
|
|
3584
|
+
actingUserId,
|
|
3585
|
+
actingUserId
|
|
3586
|
+
]
|
|
3138
3587
|
);
|
|
3139
|
-
await this.recordAuditEntry(actingUserId, "create", "
|
|
3588
|
+
await this.recordAuditEntry(actingUserId, "create", "invitation", invitation.id);
|
|
3589
|
+
return invitation;
|
|
3590
|
+
}
|
|
3591
|
+
/**
|
|
3592
|
+
* Finds an invitation by stable identifier.
|
|
3593
|
+
*
|
|
3594
|
+
* @param id - Invitation identifier to look up.
|
|
3595
|
+
*/
|
|
3596
|
+
async findInvitationById(id) {
|
|
3140
3597
|
const rows = await this.queryRows(
|
|
3141
|
-
`${
|
|
3598
|
+
`${INVITATION_SELECT} WHERE id = ? LIMIT 1`,
|
|
3142
3599
|
[id]
|
|
3143
3600
|
);
|
|
3144
3601
|
const row = rows[0];
|
|
3145
|
-
|
|
3146
|
-
throw new Error("Collection not found after insert");
|
|
3147
|
-
}
|
|
3148
|
-
return mapCollectionSqlRow(row);
|
|
3602
|
+
return row ? mapInvitationSqlRow(row) : null;
|
|
3149
3603
|
}
|
|
3150
3604
|
/**
|
|
3151
|
-
*
|
|
3605
|
+
* Finds an invitation by the sha256 hash of its secret.
|
|
3606
|
+
*
|
|
3607
|
+
* @param codeHash - sha256 hex digest of the invitation secret.
|
|
3608
|
+
*/
|
|
3609
|
+
async findInvitationByCodeHash(codeHash) {
|
|
3610
|
+
const rows = await this.queryRows(
|
|
3611
|
+
`${INVITATION_SELECT} WHERE code_hash = ? LIMIT 1`,
|
|
3612
|
+
[codeHash]
|
|
3613
|
+
);
|
|
3614
|
+
const row = rows[0];
|
|
3615
|
+
return row ? mapInvitationSqlRow(row) : null;
|
|
3616
|
+
}
|
|
3617
|
+
/**
|
|
3618
|
+
* Lists all invitations ordered by creation time descending.
|
|
3619
|
+
*/
|
|
3620
|
+
async listInvitations() {
|
|
3621
|
+
const rows = await this.queryRows(
|
|
3622
|
+
`${INVITATION_SELECT} ORDER BY created_at DESC`
|
|
3623
|
+
);
|
|
3624
|
+
return rows.map(mapInvitationSqlRow);
|
|
3625
|
+
}
|
|
3626
|
+
/**
|
|
3627
|
+
* Revokes a pending invitation by id.
|
|
3628
|
+
*
|
|
3629
|
+
* @param id - Invitation identifier to revoke.
|
|
3630
|
+
* @param actingUserId - User performing the revoke action.
|
|
3631
|
+
*/
|
|
3632
|
+
async revokeInvitation(id, actingUserId) {
|
|
3633
|
+
const now = /* @__PURE__ */ new Date();
|
|
3634
|
+
const result = await this.executeStatement(
|
|
3635
|
+
`UPDATE user_invitations
|
|
3636
|
+
SET revoked_at = ?,
|
|
3637
|
+
updated_by_user_id = ?
|
|
3638
|
+
WHERE id = ?
|
|
3639
|
+
AND redeemed_at IS NULL
|
|
3640
|
+
AND revoked_at IS NULL
|
|
3641
|
+
AND expires_at > ?`,
|
|
3642
|
+
[now, actingUserId, id, now]
|
|
3643
|
+
);
|
|
3644
|
+
const revoked = (result.affectedRows ?? 0) > 0;
|
|
3645
|
+
if (revoked) {
|
|
3646
|
+
await this.recordAuditEntry(actingUserId, "update", "invitation", id);
|
|
3647
|
+
}
|
|
3648
|
+
return revoked;
|
|
3649
|
+
}
|
|
3650
|
+
/**
|
|
3651
|
+
* Atomically consumes a pending invitation and issues a permanent API token.
|
|
3652
|
+
*
|
|
3653
|
+
* @param codeHash - sha256 hex digest of the invitation secret.
|
|
3654
|
+
* @param tokenName - Label stored on the newly created API token.
|
|
3655
|
+
* @param actingUserId - Internal user attributed with the redemption action.
|
|
3656
|
+
*/
|
|
3657
|
+
async redeemInvitation(codeHash, tokenName, actingUserId) {
|
|
3658
|
+
const now = /* @__PURE__ */ new Date();
|
|
3659
|
+
const connection = await this.requirePool().getConnection();
|
|
3660
|
+
try {
|
|
3661
|
+
await connection.beginTransaction();
|
|
3662
|
+
const [updateResult] = await connection.execute(
|
|
3663
|
+
`UPDATE user_invitations
|
|
3664
|
+
SET redeemed_at = ?,
|
|
3665
|
+
updated_by_user_id = ?
|
|
3666
|
+
WHERE code_hash = ?
|
|
3667
|
+
AND redeemed_at IS NULL
|
|
3668
|
+
AND revoked_at IS NULL
|
|
3669
|
+
AND expires_at > ?`,
|
|
3670
|
+
[now, actingUserId, codeHash, now]
|
|
3671
|
+
);
|
|
3672
|
+
if ((updateResult.affectedRows ?? 0) === 0) {
|
|
3673
|
+
const [existingRows] = await connection.execute(
|
|
3674
|
+
`${INVITATION_SELECT} WHERE code_hash = ? LIMIT 1`,
|
|
3675
|
+
[codeHash]
|
|
3676
|
+
);
|
|
3677
|
+
const existingRow = existingRows[0];
|
|
3678
|
+
if (!existingRow) {
|
|
3679
|
+
throw new InvitationUnavailableError("not_found");
|
|
3680
|
+
}
|
|
3681
|
+
const existing = mapInvitationSqlRow(existingRow);
|
|
3682
|
+
if (existing.redeemedAt) {
|
|
3683
|
+
throw new InvitationUnavailableError("redeemed");
|
|
3684
|
+
}
|
|
3685
|
+
if (existing.revokedAt) {
|
|
3686
|
+
throw new InvitationUnavailableError("revoked");
|
|
3687
|
+
}
|
|
3688
|
+
throw new InvitationUnavailableError("expired");
|
|
3689
|
+
}
|
|
3690
|
+
const [rows] = await connection.execute(
|
|
3691
|
+
`${INVITATION_SELECT} WHERE code_hash = ? LIMIT 1`,
|
|
3692
|
+
[codeHash]
|
|
3693
|
+
);
|
|
3694
|
+
const invitationRow = rows[0];
|
|
3695
|
+
if (!invitationRow) {
|
|
3696
|
+
throw new Error("Invitation not found after claim");
|
|
3697
|
+
}
|
|
3698
|
+
const invitation = mapInvitationSqlRow(invitationRow);
|
|
3699
|
+
const [userRows] = await connection.execute(
|
|
3700
|
+
`${USER_SELECT} WHERE id = ? LIMIT 1`,
|
|
3701
|
+
[invitation.userId]
|
|
3702
|
+
);
|
|
3703
|
+
const userRow = userRows[0];
|
|
3704
|
+
if (!userRow) {
|
|
3705
|
+
throw new Error("User not found");
|
|
3706
|
+
}
|
|
3707
|
+
const user = mapUserSqlRow(userRow);
|
|
3708
|
+
const { record, secret } = generateApiToken(user.id, tokenName);
|
|
3709
|
+
await connection.execute(
|
|
3710
|
+
`INSERT INTO api_tokens (
|
|
3711
|
+
id,
|
|
3712
|
+
user_id,
|
|
3713
|
+
name,
|
|
3714
|
+
token_hash,
|
|
3715
|
+
token_prefix,
|
|
3716
|
+
created_at,
|
|
3717
|
+
last_used_at,
|
|
3718
|
+
revoked_at,
|
|
3719
|
+
created_by_user_id,
|
|
3720
|
+
updated_by_user_id
|
|
3721
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
3722
|
+
[
|
|
3723
|
+
record.id,
|
|
3724
|
+
record.userId,
|
|
3725
|
+
record.name,
|
|
3726
|
+
record.tokenHash,
|
|
3727
|
+
record.tokenPrefix,
|
|
3728
|
+
record.createdAt,
|
|
3729
|
+
record.lastUsedAt,
|
|
3730
|
+
record.revokedAt,
|
|
3731
|
+
actingUserId,
|
|
3732
|
+
actingUserId
|
|
3733
|
+
]
|
|
3734
|
+
);
|
|
3735
|
+
await connection.commit();
|
|
3736
|
+
await this.recordAuditEntry(actingUserId, "update", "invitation", invitation.id);
|
|
3737
|
+
await this.recordAuditEntry(actingUserId, "create", "api_token", record.id);
|
|
3738
|
+
return { user, token: record, secret };
|
|
3739
|
+
} catch (error) {
|
|
3740
|
+
await connection.rollback();
|
|
3741
|
+
throw error;
|
|
3742
|
+
} finally {
|
|
3743
|
+
connection.release();
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3746
|
+
/**
|
|
3747
|
+
* Lists all collections ordered by name.
|
|
3748
|
+
*/
|
|
3749
|
+
async listCollections() {
|
|
3750
|
+
const rows = await this.queryRows(
|
|
3751
|
+
`${COLLECTION_SELECT} ORDER BY name ASC`
|
|
3752
|
+
);
|
|
3753
|
+
return rows.map(mapCollectionSqlRow);
|
|
3754
|
+
}
|
|
3755
|
+
/**
|
|
3756
|
+
* Creates a new collection with the given name.
|
|
3757
|
+
*
|
|
3758
|
+
* @param name - Display name for the collection.
|
|
3759
|
+
* @param actingUserId - User performing the create action.
|
|
3760
|
+
*/
|
|
3761
|
+
async createCollection(name, actingUserId) {
|
|
3762
|
+
const trimmedName = trimRequiredName(name, "Collection name");
|
|
3763
|
+
const id = randomUUID4();
|
|
3764
|
+
const now = /* @__PURE__ */ new Date();
|
|
3765
|
+
await this.executeStatement(
|
|
3766
|
+
`INSERT INTO collections (
|
|
3767
|
+
id,
|
|
3768
|
+
name,
|
|
3769
|
+
variables,
|
|
3770
|
+
headers,
|
|
3771
|
+
auth,
|
|
3772
|
+
pre_request_script,
|
|
3773
|
+
post_request_script,
|
|
3774
|
+
created_at,
|
|
3775
|
+
updated_at,
|
|
3776
|
+
created_by_user_id,
|
|
3777
|
+
updated_by_user_id
|
|
3778
|
+
) VALUES (?, ?, '[]', '[]', ?, '', '', ?, ?, ?, ?)`,
|
|
3779
|
+
[id, trimmedName, MYSQL_DEFAULT_AUTH_JSON, now, now, actingUserId, actingUserId]
|
|
3780
|
+
);
|
|
3781
|
+
await this.recordAuditEntry(actingUserId, "create", "collection", id);
|
|
3782
|
+
const rows = await this.queryRows(
|
|
3783
|
+
`${COLLECTION_SELECT} WHERE id = ?`,
|
|
3784
|
+
[id]
|
|
3785
|
+
);
|
|
3786
|
+
const row = rows[0];
|
|
3787
|
+
if (!row) {
|
|
3788
|
+
throw new Error("Collection not found after insert");
|
|
3789
|
+
}
|
|
3790
|
+
return mapCollectionSqlRow(row);
|
|
3791
|
+
}
|
|
3792
|
+
/**
|
|
3793
|
+
* Updates a collection's name, variables, headers, and scripts.
|
|
3152
3794
|
*
|
|
3153
3795
|
* @param actingUserId - User performing the update action.
|
|
3154
3796
|
*/
|
|
@@ -3263,7 +3905,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
3263
3905
|
*/
|
|
3264
3906
|
async createEnvironment(name, actingUserId) {
|
|
3265
3907
|
const trimmedName = trimRequiredName(name, "Environment name");
|
|
3266
|
-
const id =
|
|
3908
|
+
const id = randomUUID4();
|
|
3267
3909
|
const now = /* @__PURE__ */ new Date();
|
|
3268
3910
|
await this.executeStatement(
|
|
3269
3911
|
`INSERT INTO environments (
|
|
@@ -3392,7 +4034,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
3392
4034
|
*/
|
|
3393
4035
|
async createSnippet(name, code, scope, actingUserId) {
|
|
3394
4036
|
const trimmedName = trimRequiredName(name, "Snippet name");
|
|
3395
|
-
const id =
|
|
4037
|
+
const id = randomUUID4();
|
|
3396
4038
|
const now = /* @__PURE__ */ new Date();
|
|
3397
4039
|
const maxRows = await this.queryRows(
|
|
3398
4040
|
"SELECT COALESCE(MAX(sort_order), -1) AS max_order FROM snippets"
|
|
@@ -3615,7 +4257,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
3615
4257
|
[input.collectionId, folderId, folderId]
|
|
3616
4258
|
);
|
|
3617
4259
|
const maxOrder = maxRows[0]?.max_order ?? -1;
|
|
3618
|
-
const id =
|
|
4260
|
+
const id = randomUUID4();
|
|
3619
4261
|
await this.executeStatement(
|
|
3620
4262
|
`INSERT INTO requests (
|
|
3621
4263
|
id,
|
|
@@ -3715,7 +4357,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
3715
4357
|
*/
|
|
3716
4358
|
async createFolder(collectionId, name, actingUserId) {
|
|
3717
4359
|
const trimmedName = trimRequiredName(name, "Folder name");
|
|
3718
|
-
const id =
|
|
4360
|
+
const id = randomUUID4();
|
|
3719
4361
|
const now = /* @__PURE__ */ new Date();
|
|
3720
4362
|
const maxRows = await this.queryRows(
|
|
3721
4363
|
"SELECT COALESCE(MAX(sort_order), -1) AS max_order FROM folders WHERE collection_id = ?",
|
|
@@ -3969,7 +4611,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
3969
4611
|
async addLlmUsage(userId, period, promptTokens, completionTokens) {
|
|
3970
4612
|
const totalDelta = promptTokens + completionTokens;
|
|
3971
4613
|
const now = /* @__PURE__ */ new Date();
|
|
3972
|
-
const id =
|
|
4614
|
+
const id = randomUUID4();
|
|
3973
4615
|
await this.executeStatement(
|
|
3974
4616
|
`INSERT INTO llm_usage (
|
|
3975
4617
|
id,
|
|
@@ -3999,7 +4641,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
3999
4641
|
* @param input - Usage details for one successful completion step.
|
|
4000
4642
|
*/
|
|
4001
4643
|
async createLlmUsageLog(input) {
|
|
4002
|
-
const id =
|
|
4644
|
+
const id = randomUUID4();
|
|
4003
4645
|
const now = /* @__PURE__ */ new Date();
|
|
4004
4646
|
await this.executeStatement(
|
|
4005
4647
|
`INSERT INTO llm_usage_log (
|
|
@@ -4077,7 +4719,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
4077
4719
|
async createRunResult(input, actingUserId) {
|
|
4078
4720
|
const metadata = parseRunResultPayload(input.payload);
|
|
4079
4721
|
const label = input.label?.trim() || buildDefaultRunResultLabel(metadata);
|
|
4080
|
-
const id =
|
|
4722
|
+
const id = randomUUID4();
|
|
4081
4723
|
const now = /* @__PURE__ */ new Date();
|
|
4082
4724
|
await this.executeStatement(
|
|
4083
4725
|
`INSERT INTO run_results (
|
|
@@ -4149,7 +4791,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
4149
4791
|
return;
|
|
4150
4792
|
}
|
|
4151
4793
|
const input = createSystemUserInput();
|
|
4152
|
-
const id =
|
|
4794
|
+
const id = randomUUID4();
|
|
4153
4795
|
const now = /* @__PURE__ */ new Date();
|
|
4154
4796
|
const trimmedName = trimRequiredName(input.name, "User name");
|
|
4155
4797
|
await this.executeStatement(
|
|
@@ -4197,7 +4839,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
4197
4839
|
*/
|
|
4198
4840
|
async recordAuditEntry(actingUserId, action, entityType, entityId, metadata) {
|
|
4199
4841
|
const userName = await resolveActingUserName(this.findUserById.bind(this), actingUserId);
|
|
4200
|
-
const id =
|
|
4842
|
+
const id = randomUUID4();
|
|
4201
4843
|
const now = /* @__PURE__ */ new Date();
|
|
4202
4844
|
await this.executeStatement(
|
|
4203
4845
|
`INSERT INTO audit_log (
|
|
@@ -4259,9 +4901,24 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
4259
4901
|
};
|
|
4260
4902
|
|
|
4261
4903
|
// src/db/postgres/PostgresDatabase.ts
|
|
4262
|
-
import { randomUUID as
|
|
4904
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
4263
4905
|
import pg from "pg";
|
|
4264
4906
|
|
|
4907
|
+
// src/db/postgres/invitationSql.ts
|
|
4908
|
+
var INVITATION_SELECT_COLUMNS2 = `
|
|
4909
|
+
id,
|
|
4910
|
+
user_id,
|
|
4911
|
+
code_hash,
|
|
4912
|
+
code_prefix,
|
|
4913
|
+
expires_at,
|
|
4914
|
+
redeemed_at,
|
|
4915
|
+
revoked_at,
|
|
4916
|
+
created_at,
|
|
4917
|
+
created_by_user_id,
|
|
4918
|
+
updated_by_user_id
|
|
4919
|
+
`.trim();
|
|
4920
|
+
var INVITATION_SELECT2 = `SELECT ${INVITATION_SELECT_COLUMNS2} FROM user_invitations`;
|
|
4921
|
+
|
|
4265
4922
|
// src/db/postgres/migrations.ts
|
|
4266
4923
|
var API_TOKENS_MIGRATION_SQL2 = `
|
|
4267
4924
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
@@ -4501,6 +5158,22 @@ CREATE TABLE IF NOT EXISTS run_results (
|
|
|
4501
5158
|
);
|
|
4502
5159
|
CREATE INDEX IF NOT EXISTS run_results_created_idx ON run_results (created_at DESC);
|
|
4503
5160
|
`.trim();
|
|
5161
|
+
var USER_INVITATIONS_MIGRATION_SQL2 = `
|
|
5162
|
+
CREATE TABLE IF NOT EXISTS user_invitations (
|
|
5163
|
+
id TEXT PRIMARY KEY,
|
|
5164
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
5165
|
+
code_hash CHAR(64) NOT NULL UNIQUE,
|
|
5166
|
+
code_prefix TEXT NOT NULL,
|
|
5167
|
+
expires_at TIMESTAMPTZ NOT NULL,
|
|
5168
|
+
redeemed_at TIMESTAMPTZ,
|
|
5169
|
+
revoked_at TIMESTAMPTZ,
|
|
5170
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
5171
|
+
created_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
|
5172
|
+
updated_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL
|
|
5173
|
+
);
|
|
5174
|
+
CREATE INDEX IF NOT EXISTS user_invitations_user_id_idx ON user_invitations (user_id);
|
|
5175
|
+
CREATE INDEX IF NOT EXISTS user_invitations_expires_at_idx ON user_invitations (expires_at);
|
|
5176
|
+
`.trim();
|
|
4504
5177
|
var POSTGRES_MIGRATIONS = [
|
|
4505
5178
|
USERS_MIGRATION_SQL2,
|
|
4506
5179
|
API_TOKENS_MIGRATION_SQL2,
|
|
@@ -4527,7 +5200,8 @@ var POSTGRES_MIGRATIONS = [
|
|
|
4527
5200
|
ENVIRONMENTS_DELETION_LOCKED_MIGRATION_SQL2,
|
|
4528
5201
|
USERS_SNIPPET_ACCESS_MIGRATION_SQL2,
|
|
4529
5202
|
USERS_SNIPPET_ACCESS_BACKFILL_SQL2,
|
|
4530
|
-
RUN_RESULTS_MIGRATION_SQL2
|
|
5203
|
+
RUN_RESULTS_MIGRATION_SQL2,
|
|
5204
|
+
USER_INVITATIONS_MIGRATION_SQL2
|
|
4531
5205
|
];
|
|
4532
5206
|
|
|
4533
5207
|
// src/db/postgres/schemas.ts
|
|
@@ -4685,7 +5359,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
4685
5359
|
async createUser(input, actingUserId) {
|
|
4686
5360
|
const trimmedName = trimRequiredName(input.name, "User name");
|
|
4687
5361
|
assertUserNameNotReserved(trimmedName);
|
|
4688
|
-
const id =
|
|
5362
|
+
const id = randomUUID5();
|
|
4689
5363
|
const now = /* @__PURE__ */ new Date();
|
|
4690
5364
|
const result = await this.query(
|
|
4691
5365
|
`INSERT INTO users (
|
|
@@ -5003,6 +5677,286 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5003
5677
|
async touchApiTokenLastUsed(id, when) {
|
|
5004
5678
|
await this.query(`UPDATE api_tokens SET last_used_at = $2 WHERE id = $1`, [id, when]);
|
|
5005
5679
|
}
|
|
5680
|
+
/**
|
|
5681
|
+
* Creates a user account and its initial onboarding invitation in one transaction.
|
|
5682
|
+
*
|
|
5683
|
+
* @param userId - Pre-generated stable identifier for the new user.
|
|
5684
|
+
* @param input - User fields to persist.
|
|
5685
|
+
* @param invitation - Invitation metadata including the stored code hash.
|
|
5686
|
+
* @param actingUserId - User performing the create action.
|
|
5687
|
+
*/
|
|
5688
|
+
async createInvitedUser(userId, input, invitation, actingUserId) {
|
|
5689
|
+
const trimmedName = trimRequiredName(input.name, "User name");
|
|
5690
|
+
assertUserNameNotReserved(trimmedName);
|
|
5691
|
+
const now = /* @__PURE__ */ new Date();
|
|
5692
|
+
const client = await this.requirePool().connect();
|
|
5693
|
+
try {
|
|
5694
|
+
await client.query("BEGIN");
|
|
5695
|
+
const userResult = await client.query(
|
|
5696
|
+
`INSERT INTO users (
|
|
5697
|
+
id,
|
|
5698
|
+
name,
|
|
5699
|
+
role,
|
|
5700
|
+
collection_access,
|
|
5701
|
+
environment_access,
|
|
5702
|
+
snippet_access,
|
|
5703
|
+
llm_access,
|
|
5704
|
+
llm_models,
|
|
5705
|
+
llm_monthly_token_limit,
|
|
5706
|
+
created_at,
|
|
5707
|
+
updated_at,
|
|
5708
|
+
created_by_user_id,
|
|
5709
|
+
updated_by_user_id
|
|
5710
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
|
5711
|
+
RETURNING ${USER_SELECT_COLUMNS}`,
|
|
5712
|
+
[
|
|
5713
|
+
userId,
|
|
5714
|
+
trimmedName,
|
|
5715
|
+
input.role,
|
|
5716
|
+
serializeAccessList(input.collectionAccess),
|
|
5717
|
+
serializeAccessList(input.environmentAccess),
|
|
5718
|
+
serializeAccessList(input.snippetAccess),
|
|
5719
|
+
input.llmAccess ?? false,
|
|
5720
|
+
serializeAccessList(input.llmModels ?? []),
|
|
5721
|
+
input.llmMonthlyTokenLimit ?? null,
|
|
5722
|
+
now,
|
|
5723
|
+
now,
|
|
5724
|
+
actingUserId,
|
|
5725
|
+
actingUserId
|
|
5726
|
+
]
|
|
5727
|
+
);
|
|
5728
|
+
const userRow = userResult.rows[0];
|
|
5729
|
+
if (!userRow) {
|
|
5730
|
+
throw new Error("User not found after insert");
|
|
5731
|
+
}
|
|
5732
|
+
await client.query(
|
|
5733
|
+
`INSERT INTO user_invitations (
|
|
5734
|
+
id,
|
|
5735
|
+
user_id,
|
|
5736
|
+
code_hash,
|
|
5737
|
+
code_prefix,
|
|
5738
|
+
expires_at,
|
|
5739
|
+
redeemed_at,
|
|
5740
|
+
revoked_at,
|
|
5741
|
+
created_at,
|
|
5742
|
+
created_by_user_id,
|
|
5743
|
+
updated_by_user_id
|
|
5744
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
|
5745
|
+
[
|
|
5746
|
+
invitation.id,
|
|
5747
|
+
invitation.userId,
|
|
5748
|
+
invitation.codeHash,
|
|
5749
|
+
invitation.codePrefix,
|
|
5750
|
+
invitation.expiresAt,
|
|
5751
|
+
invitation.redeemedAt,
|
|
5752
|
+
invitation.revokedAt,
|
|
5753
|
+
invitation.createdAt,
|
|
5754
|
+
actingUserId,
|
|
5755
|
+
actingUserId
|
|
5756
|
+
]
|
|
5757
|
+
);
|
|
5758
|
+
await client.query("COMMIT");
|
|
5759
|
+
await this.recordAuditEntry(actingUserId, "create", "user", userId);
|
|
5760
|
+
await this.recordAuditEntry(actingUserId, "create", "invitation", invitation.id);
|
|
5761
|
+
return {
|
|
5762
|
+
user: mapUserSqlRow(userRow),
|
|
5763
|
+
invitation
|
|
5764
|
+
};
|
|
5765
|
+
} catch (error) {
|
|
5766
|
+
await client.query("ROLLBACK");
|
|
5767
|
+
throw error;
|
|
5768
|
+
} finally {
|
|
5769
|
+
client.release();
|
|
5770
|
+
}
|
|
5771
|
+
}
|
|
5772
|
+
/**
|
|
5773
|
+
* Persists a new onboarding invitation for an existing user account.
|
|
5774
|
+
*
|
|
5775
|
+
* @param invitation - Invitation metadata including the stored code hash.
|
|
5776
|
+
* @param actingUserId - User performing the create action.
|
|
5777
|
+
*/
|
|
5778
|
+
async createInvitation(invitation, actingUserId) {
|
|
5779
|
+
const user = await this.findUserById(invitation.userId);
|
|
5780
|
+
if (!user) {
|
|
5781
|
+
throw new Error("User not found");
|
|
5782
|
+
}
|
|
5783
|
+
await this.query(
|
|
5784
|
+
`INSERT INTO user_invitations (
|
|
5785
|
+
id,
|
|
5786
|
+
user_id,
|
|
5787
|
+
code_hash,
|
|
5788
|
+
code_prefix,
|
|
5789
|
+
expires_at,
|
|
5790
|
+
redeemed_at,
|
|
5791
|
+
revoked_at,
|
|
5792
|
+
created_at,
|
|
5793
|
+
created_by_user_id,
|
|
5794
|
+
updated_by_user_id
|
|
5795
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
|
5796
|
+
[
|
|
5797
|
+
invitation.id,
|
|
5798
|
+
invitation.userId,
|
|
5799
|
+
invitation.codeHash,
|
|
5800
|
+
invitation.codePrefix,
|
|
5801
|
+
invitation.expiresAt,
|
|
5802
|
+
invitation.redeemedAt,
|
|
5803
|
+
invitation.revokedAt,
|
|
5804
|
+
invitation.createdAt,
|
|
5805
|
+
actingUserId,
|
|
5806
|
+
actingUserId
|
|
5807
|
+
]
|
|
5808
|
+
);
|
|
5809
|
+
await this.recordAuditEntry(actingUserId, "create", "invitation", invitation.id);
|
|
5810
|
+
return invitation;
|
|
5811
|
+
}
|
|
5812
|
+
/**
|
|
5813
|
+
* Finds an invitation by stable identifier.
|
|
5814
|
+
*
|
|
5815
|
+
* @param id - Invitation identifier to look up.
|
|
5816
|
+
*/
|
|
5817
|
+
async findInvitationById(id) {
|
|
5818
|
+
const result = await this.query(
|
|
5819
|
+
`${INVITATION_SELECT2} WHERE id = $1 LIMIT 1`,
|
|
5820
|
+
[id]
|
|
5821
|
+
);
|
|
5822
|
+
const row = result.rows[0];
|
|
5823
|
+
return row ? mapInvitationSqlRow(row) : null;
|
|
5824
|
+
}
|
|
5825
|
+
/**
|
|
5826
|
+
* Finds an invitation by the sha256 hash of its secret.
|
|
5827
|
+
*
|
|
5828
|
+
* @param codeHash - sha256 hex digest of the invitation secret.
|
|
5829
|
+
*/
|
|
5830
|
+
async findInvitationByCodeHash(codeHash) {
|
|
5831
|
+
const result = await this.query(
|
|
5832
|
+
`${INVITATION_SELECT2} WHERE code_hash = $1 LIMIT 1`,
|
|
5833
|
+
[codeHash]
|
|
5834
|
+
);
|
|
5835
|
+
const row = result.rows[0];
|
|
5836
|
+
return row ? mapInvitationSqlRow(row) : null;
|
|
5837
|
+
}
|
|
5838
|
+
/**
|
|
5839
|
+
* Lists all invitations ordered by creation time descending.
|
|
5840
|
+
*/
|
|
5841
|
+
async listInvitations() {
|
|
5842
|
+
const result = await this.query(
|
|
5843
|
+
`${INVITATION_SELECT2} ORDER BY created_at DESC`
|
|
5844
|
+
);
|
|
5845
|
+
return result.rows.map(mapInvitationSqlRow);
|
|
5846
|
+
}
|
|
5847
|
+
/**
|
|
5848
|
+
* Revokes a pending invitation by id.
|
|
5849
|
+
*
|
|
5850
|
+
* @param id - Invitation identifier to revoke.
|
|
5851
|
+
* @param actingUserId - User performing the revoke action.
|
|
5852
|
+
*/
|
|
5853
|
+
async revokeInvitation(id, actingUserId) {
|
|
5854
|
+
const now = /* @__PURE__ */ new Date();
|
|
5855
|
+
const result = await this.query(
|
|
5856
|
+
`UPDATE user_invitations
|
|
5857
|
+
SET revoked_at = $1, updated_by_user_id = $2
|
|
5858
|
+
WHERE id = $3
|
|
5859
|
+
AND redeemed_at IS NULL
|
|
5860
|
+
AND revoked_at IS NULL
|
|
5861
|
+
AND expires_at > $1
|
|
5862
|
+
RETURNING id`,
|
|
5863
|
+
[now, actingUserId, id]
|
|
5864
|
+
);
|
|
5865
|
+
if (!result.rows[0]) {
|
|
5866
|
+
return false;
|
|
5867
|
+
}
|
|
5868
|
+
await this.recordAuditEntry(actingUserId, "update", "invitation", id);
|
|
5869
|
+
return true;
|
|
5870
|
+
}
|
|
5871
|
+
/**
|
|
5872
|
+
* Atomically consumes a pending invitation and issues a permanent API token.
|
|
5873
|
+
*
|
|
5874
|
+
* @param codeHash - sha256 hex digest of the invitation secret.
|
|
5875
|
+
* @param tokenName - Label stored on the newly created API token.
|
|
5876
|
+
* @param actingUserId - Internal user attributed with the redemption action.
|
|
5877
|
+
*/
|
|
5878
|
+
async redeemInvitation(codeHash, tokenName, actingUserId) {
|
|
5879
|
+
const now = /* @__PURE__ */ new Date();
|
|
5880
|
+
const client = await this.requirePool().connect();
|
|
5881
|
+
try {
|
|
5882
|
+
await client.query("BEGIN");
|
|
5883
|
+
const claimResult = await client.query(
|
|
5884
|
+
`UPDATE user_invitations
|
|
5885
|
+
SET redeemed_at = $1, updated_by_user_id = $2
|
|
5886
|
+
WHERE code_hash = $3
|
|
5887
|
+
AND redeemed_at IS NULL
|
|
5888
|
+
AND revoked_at IS NULL
|
|
5889
|
+
AND expires_at > $1
|
|
5890
|
+
RETURNING ${INVITATION_SELECT_COLUMNS2}`,
|
|
5891
|
+
[now, actingUserId, codeHash]
|
|
5892
|
+
);
|
|
5893
|
+
const invitationRow = claimResult.rows[0];
|
|
5894
|
+
if (!invitationRow) {
|
|
5895
|
+
const existingResult = await client.query(
|
|
5896
|
+
`${INVITATION_SELECT2} WHERE code_hash = $1 LIMIT 1`,
|
|
5897
|
+
[codeHash]
|
|
5898
|
+
);
|
|
5899
|
+
const existingRow = existingResult.rows[0];
|
|
5900
|
+
if (!existingRow) {
|
|
5901
|
+
throw new InvitationUnavailableError("not_found");
|
|
5902
|
+
}
|
|
5903
|
+
const existing = mapInvitationSqlRow(existingRow);
|
|
5904
|
+
if (existing.redeemedAt) {
|
|
5905
|
+
throw new InvitationUnavailableError("redeemed");
|
|
5906
|
+
}
|
|
5907
|
+
if (existing.revokedAt) {
|
|
5908
|
+
throw new InvitationUnavailableError("revoked");
|
|
5909
|
+
}
|
|
5910
|
+
throw new InvitationUnavailableError("expired");
|
|
5911
|
+
}
|
|
5912
|
+
const invitation = mapInvitationSqlRow(invitationRow);
|
|
5913
|
+
const userResult = await client.query(`${USER_SELECT2} WHERE id = $1 LIMIT 1`, [
|
|
5914
|
+
invitation.userId
|
|
5915
|
+
]);
|
|
5916
|
+
const userRow = userResult.rows[0];
|
|
5917
|
+
if (!userRow) {
|
|
5918
|
+
throw new Error("User not found");
|
|
5919
|
+
}
|
|
5920
|
+
const user = mapUserSqlRow(userRow);
|
|
5921
|
+
const effectiveTokenName = tokenName.trim().length > 0 ? tokenName.trim() : user.name;
|
|
5922
|
+
const { record, secret } = generateApiToken(user.id, effectiveTokenName);
|
|
5923
|
+
await client.query(
|
|
5924
|
+
`INSERT INTO api_tokens (
|
|
5925
|
+
id,
|
|
5926
|
+
user_id,
|
|
5927
|
+
name,
|
|
5928
|
+
token_hash,
|
|
5929
|
+
token_prefix,
|
|
5930
|
+
created_at,
|
|
5931
|
+
last_used_at,
|
|
5932
|
+
revoked_at,
|
|
5933
|
+
created_by_user_id,
|
|
5934
|
+
updated_by_user_id
|
|
5935
|
+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
|
5936
|
+
[
|
|
5937
|
+
record.id,
|
|
5938
|
+
record.userId,
|
|
5939
|
+
record.name,
|
|
5940
|
+
record.tokenHash,
|
|
5941
|
+
record.tokenPrefix,
|
|
5942
|
+
record.createdAt,
|
|
5943
|
+
record.lastUsedAt,
|
|
5944
|
+
record.revokedAt,
|
|
5945
|
+
actingUserId,
|
|
5946
|
+
actingUserId
|
|
5947
|
+
]
|
|
5948
|
+
);
|
|
5949
|
+
await client.query("COMMIT");
|
|
5950
|
+
await this.recordAuditEntry(actingUserId, "update", "invitation", invitation.id);
|
|
5951
|
+
await this.recordAuditEntry(actingUserId, "create", "api_token", record.id);
|
|
5952
|
+
return { user, token: record, secret };
|
|
5953
|
+
} catch (error) {
|
|
5954
|
+
await client.query("ROLLBACK");
|
|
5955
|
+
throw error;
|
|
5956
|
+
} finally {
|
|
5957
|
+
client.release();
|
|
5958
|
+
}
|
|
5959
|
+
}
|
|
5006
5960
|
/**
|
|
5007
5961
|
* Lists all collections ordered by name.
|
|
5008
5962
|
*/
|
|
@@ -5018,7 +5972,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5018
5972
|
*/
|
|
5019
5973
|
async createCollection(name, actingUserId) {
|
|
5020
5974
|
const trimmedName = trimRequiredName(name, "Collection name");
|
|
5021
|
-
const id =
|
|
5975
|
+
const id = randomUUID5();
|
|
5022
5976
|
const now = /* @__PURE__ */ new Date();
|
|
5023
5977
|
const result = await this.query(
|
|
5024
5978
|
`INSERT INTO collections (
|
|
@@ -5153,7 +6107,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5153
6107
|
*/
|
|
5154
6108
|
async createEnvironment(name, actingUserId) {
|
|
5155
6109
|
const trimmedName = trimRequiredName(name, "Environment name");
|
|
5156
|
-
const id =
|
|
6110
|
+
const id = randomUUID5();
|
|
5157
6111
|
const now = /* @__PURE__ */ new Date();
|
|
5158
6112
|
const result = await this.query(
|
|
5159
6113
|
`INSERT INTO environments (
|
|
@@ -5276,7 +6230,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5276
6230
|
*/
|
|
5277
6231
|
async createSnippet(name, code, scope, actingUserId) {
|
|
5278
6232
|
const trimmedName = trimRequiredName(name, "Snippet name");
|
|
5279
|
-
const id =
|
|
6233
|
+
const id = randomUUID5();
|
|
5280
6234
|
const now = /* @__PURE__ */ new Date();
|
|
5281
6235
|
const maxResult = await this.query(
|
|
5282
6236
|
"SELECT COALESCE(MAX(sort_order), -1) AS max_order FROM snippets"
|
|
@@ -5482,7 +6436,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5482
6436
|
[input.collectionId, folderId]
|
|
5483
6437
|
);
|
|
5484
6438
|
const maxOrder = maxResult.rows[0]?.max_order ?? -1;
|
|
5485
|
-
const id =
|
|
6439
|
+
const id = randomUUID5();
|
|
5486
6440
|
const result = await this.query(
|
|
5487
6441
|
`INSERT INTO requests (
|
|
5488
6442
|
id,
|
|
@@ -5576,7 +6530,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5576
6530
|
*/
|
|
5577
6531
|
async createFolder(collectionId, name, actingUserId) {
|
|
5578
6532
|
const trimmedName = trimRequiredName(name, "Folder name");
|
|
5579
|
-
const id =
|
|
6533
|
+
const id = randomUUID5();
|
|
5580
6534
|
const now = /* @__PURE__ */ new Date();
|
|
5581
6535
|
const maxResult = await this.query(
|
|
5582
6536
|
"SELECT COALESCE(MAX(sort_order), -1) AS max_order FROM folders WHERE collection_id = $1",
|
|
@@ -5823,7 +6777,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5823
6777
|
async addLlmUsage(userId, period, promptTokens, completionTokens) {
|
|
5824
6778
|
const totalDelta = promptTokens + completionTokens;
|
|
5825
6779
|
const now = /* @__PURE__ */ new Date();
|
|
5826
|
-
const id =
|
|
6780
|
+
const id = randomUUID5();
|
|
5827
6781
|
const result = await this.query(
|
|
5828
6782
|
`INSERT INTO llm_usage (
|
|
5829
6783
|
id,
|
|
@@ -5854,7 +6808,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5854
6808
|
* @param input - Usage details for one successful completion step.
|
|
5855
6809
|
*/
|
|
5856
6810
|
async createLlmUsageLog(input) {
|
|
5857
|
-
const id =
|
|
6811
|
+
const id = randomUUID5();
|
|
5858
6812
|
const now = /* @__PURE__ */ new Date();
|
|
5859
6813
|
const result = await this.query(
|
|
5860
6814
|
`INSERT INTO llm_usage_log (
|
|
@@ -5929,7 +6883,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5929
6883
|
async createRunResult(input, actingUserId) {
|
|
5930
6884
|
const metadata = parseRunResultPayload(input.payload);
|
|
5931
6885
|
const label = input.label?.trim() || buildDefaultRunResultLabel(metadata);
|
|
5932
|
-
const id =
|
|
6886
|
+
const id = randomUUID5();
|
|
5933
6887
|
const now = /* @__PURE__ */ new Date();
|
|
5934
6888
|
const result = await this.query(
|
|
5935
6889
|
`INSERT INTO run_results (
|
|
@@ -6009,7 +6963,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
6009
6963
|
this.systemUserId = existing.id;
|
|
6010
6964
|
return;
|
|
6011
6965
|
}
|
|
6012
|
-
const id =
|
|
6966
|
+
const id = randomUUID5();
|
|
6013
6967
|
const now = /* @__PURE__ */ new Date();
|
|
6014
6968
|
const input = createSystemUserInput();
|
|
6015
6969
|
await this.query(
|
|
@@ -6060,7 +7014,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
6060
7014
|
(userId) => this.findUserById(userId),
|
|
6061
7015
|
actingUserId
|
|
6062
7016
|
);
|
|
6063
|
-
const id =
|
|
7017
|
+
const id = randomUUID5();
|
|
6064
7018
|
const now = /* @__PURE__ */ new Date();
|
|
6065
7019
|
await this.query(
|
|
6066
7020
|
`INSERT INTO audit_log (
|
|
@@ -6264,39 +7218,41 @@ function registerMigrateCommand(program, handler = migrateCommand) {
|
|
|
6264
7218
|
|
|
6265
7219
|
// src/cli/userCommand.ts
|
|
6266
7220
|
import { InvalidArgumentError } from "commander";
|
|
7221
|
+
import { randomUUID as randomUUID7 } from "crypto";
|
|
6267
7222
|
|
|
6268
|
-
// src/server/auth/
|
|
6269
|
-
import { createHash, randomBytes, randomUUID as
|
|
6270
|
-
var
|
|
6271
|
-
|
|
6272
|
-
|
|
6273
|
-
|
|
6274
|
-
|
|
6275
|
-
|
|
6276
|
-
|
|
6277
|
-
|
|
7223
|
+
// src/server/auth/invitations.ts
|
|
7224
|
+
import { createHash as createHash2, randomBytes as randomBytes2, randomUUID as randomUUID6 } from "crypto";
|
|
7225
|
+
var INVITATION_PREFIX = "hbi_";
|
|
7226
|
+
var DEFAULT_INVITATION_EXPIRES_IN_HOURS = 24;
|
|
7227
|
+
function hashInvitationSecret(secret) {
|
|
7228
|
+
return createHash2("sha256").update(secret).digest("hex");
|
|
7229
|
+
}
|
|
7230
|
+
function isInvitationSecretFormat(secret) {
|
|
7231
|
+
return secret.startsWith(INVITATION_PREFIX) && secret.length > INVITATION_PREFIX.length + 8;
|
|
7232
|
+
}
|
|
7233
|
+
function resolveInvitationExpiresAt(expiresInHours) {
|
|
7234
|
+
const hours = expiresInHours !== void 0 && expiresInHours > 0 ? expiresInHours : DEFAULT_INVITATION_EXPIRES_IN_HOURS;
|
|
7235
|
+
return new Date(Date.now() + hours * 60 * 60 * 1e3);
|
|
7236
|
+
}
|
|
7237
|
+
function generateInvitation(userId, actingUserId, expiresAt) {
|
|
7238
|
+
const secretSuffix = randomBytes2(32).toString("base64url");
|
|
7239
|
+
const secret = `${INVITATION_PREFIX}${secretSuffix}`;
|
|
7240
|
+
const codePrefix = `${INVITATION_PREFIX}${secretSuffix.slice(0, 8)}`;
|
|
6278
7241
|
const createdAt = /* @__PURE__ */ new Date();
|
|
6279
7242
|
const record = {
|
|
6280
|
-
id:
|
|
7243
|
+
id: randomUUID6(),
|
|
6281
7244
|
userId,
|
|
6282
|
-
|
|
6283
|
-
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
lastUsedAt: null,
|
|
7245
|
+
codeHash: hashInvitationSecret(secret),
|
|
7246
|
+
codePrefix,
|
|
7247
|
+
expiresAt,
|
|
7248
|
+
redeemedAt: null,
|
|
6287
7249
|
revokedAt: null,
|
|
6288
|
-
|
|
6289
|
-
|
|
7250
|
+
createdAt,
|
|
7251
|
+
createdByUserId: actingUserId,
|
|
7252
|
+
updatedByUserId: actingUserId
|
|
6290
7253
|
};
|
|
6291
7254
|
return { record, secret };
|
|
6292
7255
|
}
|
|
6293
|
-
function extractBearer(headerValue) {
|
|
6294
|
-
if (!headerValue) {
|
|
6295
|
-
return null;
|
|
6296
|
-
}
|
|
6297
|
-
const match = /^Bearer\s+(\S+)$/i.exec(headerValue.trim());
|
|
6298
|
-
return match?.[1] ?? null;
|
|
6299
|
-
}
|
|
6300
7256
|
|
|
6301
7257
|
// src/server/admin/userValidation.ts
|
|
6302
7258
|
var ValidationError = class extends Error {
|
|
@@ -6470,6 +7426,14 @@ var LLM_MODEL_CATALOG = [
|
|
|
6470
7426
|
function hasProviderKey(config, provider) {
|
|
6471
7427
|
return Boolean(config.providers[provider]?.apiKey.trim());
|
|
6472
7428
|
}
|
|
7429
|
+
function hasHubOpenAiProvider(config) {
|
|
7430
|
+
return hasProviderKey(config, "openai");
|
|
7431
|
+
}
|
|
7432
|
+
function getHubLlmCapabilities(config) {
|
|
7433
|
+
return {
|
|
7434
|
+
openai: hasProviderKey(config, "openai")
|
|
7435
|
+
};
|
|
7436
|
+
}
|
|
6473
7437
|
function listHubOfferedModels(config) {
|
|
6474
7438
|
const allowList = config.models ? new Set(config.models) : null;
|
|
6475
7439
|
return LLM_MODEL_CATALOG.filter((model) => {
|
|
@@ -6544,6 +7508,13 @@ function parseMonthlyTokenLimit(value) {
|
|
|
6544
7508
|
}
|
|
6545
7509
|
return parsed;
|
|
6546
7510
|
}
|
|
7511
|
+
function parsePositiveInt(value) {
|
|
7512
|
+
const parsed = Number(value.trim());
|
|
7513
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
7514
|
+
throw new InvalidArgumentError("Value must be a positive integer.");
|
|
7515
|
+
}
|
|
7516
|
+
return parsed;
|
|
7517
|
+
}
|
|
6547
7518
|
function printUser(user, usage) {
|
|
6548
7519
|
console.log(`- id: ${user.id}`);
|
|
6549
7520
|
console.log(` name: ${user.name}`);
|
|
@@ -6649,6 +7620,100 @@ async function userCreateCommand(options) {
|
|
|
6649
7620
|
console.log("");
|
|
6650
7621
|
printCreatedApiToken(user, record, secret);
|
|
6651
7622
|
}
|
|
7623
|
+
function printCreatedInvitation(user, invitation, secret) {
|
|
7624
|
+
console.log(`Created invitation (${invitation.id}) for user "${user.name}" (${user.id}).`);
|
|
7625
|
+
console.log(`Invitation prefix: ${invitation.codePrefix}`);
|
|
7626
|
+
console.log(`Expires: ${invitation.expiresAt.toISOString()}`);
|
|
7627
|
+
console.log("");
|
|
7628
|
+
console.log("Store this invitation secret now; it will not be shown again:");
|
|
7629
|
+
console.log(secret);
|
|
7630
|
+
}
|
|
7631
|
+
async function userInviteCreateCommand(options) {
|
|
7632
|
+
const config = loadServerConfig(options.config);
|
|
7633
|
+
const db = createDatabase(config.db);
|
|
7634
|
+
const access = mapValidationError(
|
|
7635
|
+
() => normalizeAccessForRole(
|
|
7636
|
+
options.role,
|
|
7637
|
+
options.collectionAccess,
|
|
7638
|
+
options.environmentAccess,
|
|
7639
|
+
options.snippetAccess
|
|
7640
|
+
)
|
|
7641
|
+
);
|
|
7642
|
+
await db.connect();
|
|
7643
|
+
const actingUserId = await requireSystemUserId(db);
|
|
7644
|
+
const catalogs = await loadAccessCatalogs(db, config.llm);
|
|
7645
|
+
const llmModels = readLlmModelsOption(options);
|
|
7646
|
+
const llm = mapValidationError(
|
|
7647
|
+
() => normalizeLlmForRole(options.role, options.llmAccess ?? false, llmModels)
|
|
7648
|
+
);
|
|
7649
|
+
validateSubmittedAccessListsOrThrow(
|
|
7650
|
+
{
|
|
7651
|
+
role: options.role,
|
|
7652
|
+
collectionAccess: access.collectionAccess,
|
|
7653
|
+
environmentAccess: access.environmentAccess,
|
|
7654
|
+
snippetAccess: access.snippetAccess,
|
|
7655
|
+
llmModels
|
|
7656
|
+
},
|
|
7657
|
+
catalogs
|
|
7658
|
+
);
|
|
7659
|
+
const userId = randomUUID7();
|
|
7660
|
+
const expiresAt = resolveInvitationExpiresAt(options.expiresInHours);
|
|
7661
|
+
const { record: invitation, secret } = generateInvitation(userId, actingUserId, expiresAt);
|
|
7662
|
+
const created = await db.createInvitedUser(
|
|
7663
|
+
userId,
|
|
7664
|
+
{
|
|
7665
|
+
name: options.name,
|
|
7666
|
+
role: options.role,
|
|
7667
|
+
collectionAccess: access.collectionAccess ?? [],
|
|
7668
|
+
environmentAccess: access.environmentAccess ?? [],
|
|
7669
|
+
snippetAccess: access.snippetAccess ?? [],
|
|
7670
|
+
llmAccess: llm.llmAccess,
|
|
7671
|
+
llmModels: llm.llmModels,
|
|
7672
|
+
llmMonthlyTokenLimit: options.llmMonthlyTokens ?? null
|
|
7673
|
+
},
|
|
7674
|
+
invitation,
|
|
7675
|
+
actingUserId
|
|
7676
|
+
);
|
|
7677
|
+
await db.disconnect();
|
|
7678
|
+
console.log(
|
|
7679
|
+
`Created invited user "${created.user.name}" (${created.user.id}) with role ${created.user.role}.`
|
|
7680
|
+
);
|
|
7681
|
+
printUser(created.user);
|
|
7682
|
+
console.log("");
|
|
7683
|
+
printCreatedInvitation(created.user, created.invitation, secret);
|
|
7684
|
+
}
|
|
7685
|
+
async function userInviteListCommand(options) {
|
|
7686
|
+
const config = loadServerConfig(options.config);
|
|
7687
|
+
const db = createDatabase(config.db);
|
|
7688
|
+
await db.connect();
|
|
7689
|
+
const [invitations, users] = await Promise.all([db.listInvitations(), db.listUsers()]);
|
|
7690
|
+
await db.disconnect();
|
|
7691
|
+
if (invitations.length === 0) {
|
|
7692
|
+
console.log("No invitations found.");
|
|
7693
|
+
return;
|
|
7694
|
+
}
|
|
7695
|
+
const usersById = new Map(users.map((user) => [user.id, user.name]));
|
|
7696
|
+
for (const invitation of invitations) {
|
|
7697
|
+
const userName = usersById.get(invitation.userId) ?? invitation.userId;
|
|
7698
|
+
console.log(
|
|
7699
|
+
`${invitation.id} ${userName} ${invitation.codePrefix} ${getInvitationStatus(invitation)}`
|
|
7700
|
+
);
|
|
7701
|
+
console.log(` expires: ${invitation.expiresAt.toISOString()}`);
|
|
7702
|
+
}
|
|
7703
|
+
}
|
|
7704
|
+
async function userInviteRevokeCommand(options) {
|
|
7705
|
+
const config = loadServerConfig(options.config);
|
|
7706
|
+
const db = createDatabase(config.db);
|
|
7707
|
+
await db.connect();
|
|
7708
|
+
const actingUserId = await requireSystemUserId(db);
|
|
7709
|
+
const revoked = await db.revokeInvitation(options.id, actingUserId);
|
|
7710
|
+
await db.disconnect();
|
|
7711
|
+
if (revoked) {
|
|
7712
|
+
console.log(`Revoked invitation ${options.id}.`);
|
|
7713
|
+
return;
|
|
7714
|
+
}
|
|
7715
|
+
console.log(`No pending invitation found with id ${options.id}.`);
|
|
7716
|
+
}
|
|
6652
7717
|
async function userListCommand(options) {
|
|
6653
7718
|
const config = loadServerConfig(options.config);
|
|
6654
7719
|
const db = createDatabase(config.db);
|
|
@@ -6821,6 +7886,48 @@ async function userTokenRevokeCommand(options) {
|
|
|
6821
7886
|
}
|
|
6822
7887
|
function registerUserCommand(program, handlers = {}) {
|
|
6823
7888
|
const user = program.command("user").description("Manage user accounts and their API tokens");
|
|
7889
|
+
const invite = user.command("invite").description("Manage onboarding invitations for user accounts");
|
|
7890
|
+
invite.command("create").description("Create a user account with a single-use onboarding invitation").requiredOption("--name <name>", "Unique display name", parseRequiredName).requiredOption("--role <role>", "Account role (admin or user)", parseUserRole2).option(
|
|
7891
|
+
"--collection-access <id>",
|
|
7892
|
+
"Collection id or * (repeatable)",
|
|
7893
|
+
parseAccessFlag,
|
|
7894
|
+
[]
|
|
7895
|
+
).option(
|
|
7896
|
+
"--environment-access <id>",
|
|
7897
|
+
"Environment id or * (repeatable)",
|
|
7898
|
+
parseAccessFlag,
|
|
7899
|
+
[]
|
|
7900
|
+
).option(
|
|
7901
|
+
"--snippet-access <id>",
|
|
7902
|
+
"Snippet id or * (repeatable)",
|
|
7903
|
+
parseAccessFlag,
|
|
7904
|
+
[]
|
|
7905
|
+
).option("--llm-access", "Enable hub-proxied LLM access for the user").option("--llm-model <id>", "LLM model id or * (repeatable)", parseAccessFlag, []).option("--llm-monthly-tokens <count>", "Monthly LLM token limit", parseMonthlyTokenLimit).option("--expires-in-hours <hours>", "Invitation lifetime in hours", parsePositiveInt).action(
|
|
7906
|
+
/**
|
|
7907
|
+
* Runs the user invite create subcommand after merging global CLI options.
|
|
7908
|
+
*/
|
|
7909
|
+
async function userInviteCreateAction(options) {
|
|
7910
|
+
await (handlers.inviteCreate ?? userInviteCreateCommand)(mergeGlobalOptions(this, options));
|
|
7911
|
+
}
|
|
7912
|
+
);
|
|
7913
|
+
invite.command("list").description("List stored onboarding invitations").action(
|
|
7914
|
+
/**
|
|
7915
|
+
* Runs the user invite list subcommand after merging global CLI options.
|
|
7916
|
+
*/
|
|
7917
|
+
async function userInviteListAction(options) {
|
|
7918
|
+
await (handlers.inviteList ?? userInviteListCommand)(mergeGlobalOptions(this, options));
|
|
7919
|
+
}
|
|
7920
|
+
);
|
|
7921
|
+
invite.command("revoke").description("Revoke a pending onboarding invitation by id").argument("<id>", "Invitation identifier to revoke").action(
|
|
7922
|
+
/**
|
|
7923
|
+
* Runs the user invite revoke subcommand after merging global CLI options.
|
|
7924
|
+
*/
|
|
7925
|
+
async function userInviteRevokeAction(id, options) {
|
|
7926
|
+
await (handlers.inviteRevoke ?? userInviteRevokeCommand)(
|
|
7927
|
+
mergeGlobalOptions(this, { ...options, id })
|
|
7928
|
+
);
|
|
7929
|
+
}
|
|
7930
|
+
);
|
|
6824
7931
|
user.command("create").description("Create a new user account").requiredOption("--name <name>", "Unique display name", parseRequiredName).requiredOption("--role <role>", "Account role (admin or user)", parseUserRole2).option(
|
|
6825
7932
|
"--collection-access <id>",
|
|
6826
7933
|
"Collection id or * (repeatable)",
|
|
@@ -6992,6 +8099,9 @@ function readPackageVersion() {
|
|
|
6992
8099
|
return pkg.version;
|
|
6993
8100
|
}
|
|
6994
8101
|
|
|
8102
|
+
// src/server/routes/admin.ts
|
|
8103
|
+
import { randomUUID as randomUUID8 } from "crypto";
|
|
8104
|
+
|
|
6995
8105
|
// src/server/auth/accessControl.ts
|
|
6996
8106
|
function isAdmin(user) {
|
|
6997
8107
|
return user.role === "admin";
|
|
@@ -7285,8 +8395,12 @@ var llmModelSchema = z7.object({
|
|
|
7285
8395
|
label: z7.string(),
|
|
7286
8396
|
provider: z7.enum(["openai", "claude", "gemini"])
|
|
7287
8397
|
});
|
|
8398
|
+
var llmCapabilitiesSchema = z7.object({
|
|
8399
|
+
openai: z7.boolean()
|
|
8400
|
+
});
|
|
7288
8401
|
var listLlmModelsResponseSchema = z7.object({
|
|
7289
|
-
models: z7.array(llmModelSchema)
|
|
8402
|
+
models: z7.array(llmModelSchema),
|
|
8403
|
+
capabilities: llmCapabilitiesSchema
|
|
7290
8404
|
});
|
|
7291
8405
|
var llmUsageSummaryResponseSchema = z7.object({
|
|
7292
8406
|
period: z7.string(),
|
|
@@ -7637,7 +8751,7 @@ var listAdminTokensResponseSchema = z9.object({
|
|
|
7637
8751
|
tokens: z9.array(hubApiTokenRecordSchema)
|
|
7638
8752
|
});
|
|
7639
8753
|
var reloadConfigSectionResultSchema = z9.object({
|
|
7640
|
-
section: z9.enum(["db", "redis", "llm", "plugins", "server"]),
|
|
8754
|
+
section: z9.enum(["db", "redis", "llm", "plugins", "docs", "server"]),
|
|
7641
8755
|
status: z9.enum(["reloaded", "unchanged", "failed", "restart-required"]),
|
|
7642
8756
|
error: z9.string().optional()
|
|
7643
8757
|
});
|
|
@@ -7672,6 +8786,73 @@ function serializeApiToken(token) {
|
|
|
7672
8786
|
};
|
|
7673
8787
|
}
|
|
7674
8788
|
|
|
8789
|
+
// src/server/routes/schemas/invitations.ts
|
|
8790
|
+
import { z as z10 } from "zod/v4";
|
|
8791
|
+
var invitationStatusSchema = z10.enum(["pending", "redeemed", "revoked", "expired"]);
|
|
8792
|
+
var hubInvitationRecordSchema = z10.object({
|
|
8793
|
+
id: z10.string(),
|
|
8794
|
+
userId: z10.string(),
|
|
8795
|
+
codePrefix: z10.string(),
|
|
8796
|
+
expiresAt: timestampSchema,
|
|
8797
|
+
redeemedAt: timestampSchema.nullable(),
|
|
8798
|
+
revokedAt: timestampSchema.nullable(),
|
|
8799
|
+
createdAt: timestampSchema,
|
|
8800
|
+
status: invitationStatusSchema
|
|
8801
|
+
});
|
|
8802
|
+
var createAdminInvitedUserBodySchema = createAdminUserBodySchema.extend({
|
|
8803
|
+
expiresInHours: z10.number().int().positive().optional()
|
|
8804
|
+
});
|
|
8805
|
+
var createAdminInvitationResponseSchema = z10.object({
|
|
8806
|
+
user: hubUserRecordSchema,
|
|
8807
|
+
invitation: hubInvitationRecordSchema,
|
|
8808
|
+
secret: z10.string()
|
|
8809
|
+
});
|
|
8810
|
+
var listAdminInvitationsResponseSchema = z10.object({
|
|
8811
|
+
invitations: z10.array(hubInvitationRecordSchema)
|
|
8812
|
+
});
|
|
8813
|
+
var invitationSecretBodySchema = z10.object({
|
|
8814
|
+
secret: z10.string().trim().min(1)
|
|
8815
|
+
});
|
|
8816
|
+
var redeemInvitationBodySchema = invitationSecretBodySchema.extend({
|
|
8817
|
+
tokenName: z10.string().trim().min(1).optional()
|
|
8818
|
+
});
|
|
8819
|
+
var hubInvitationPreviewUserSchema = z10.object({
|
|
8820
|
+
name: z10.string(),
|
|
8821
|
+
role: userRoleSchema,
|
|
8822
|
+
collectionAccess: z10.array(z10.string()),
|
|
8823
|
+
environmentAccess: z10.array(z10.string()),
|
|
8824
|
+
snippetAccess: z10.array(z10.string()),
|
|
8825
|
+
llmAccess: z10.boolean(),
|
|
8826
|
+
llmModels: z10.array(z10.string())
|
|
8827
|
+
});
|
|
8828
|
+
var previewInvitationResponseSchema = z10.object({
|
|
8829
|
+
user: hubInvitationPreviewUserSchema,
|
|
8830
|
+
expiresAt: timestampSchema
|
|
8831
|
+
});
|
|
8832
|
+
function serializeHubInvitation(invitation) {
|
|
8833
|
+
return {
|
|
8834
|
+
id: invitation.id,
|
|
8835
|
+
userId: invitation.userId,
|
|
8836
|
+
codePrefix: invitation.codePrefix,
|
|
8837
|
+
expiresAt: invitation.expiresAt.toISOString(),
|
|
8838
|
+
redeemedAt: invitation.redeemedAt?.toISOString() ?? null,
|
|
8839
|
+
revokedAt: invitation.revokedAt?.toISOString() ?? null,
|
|
8840
|
+
createdAt: invitation.createdAt.toISOString(),
|
|
8841
|
+
status: getInvitationStatus(invitation)
|
|
8842
|
+
};
|
|
8843
|
+
}
|
|
8844
|
+
function serializeInvitationPreviewUser(user) {
|
|
8845
|
+
return {
|
|
8846
|
+
name: user.name,
|
|
8847
|
+
role: user.role,
|
|
8848
|
+
collectionAccess: user.collectionAccess,
|
|
8849
|
+
environmentAccess: user.environmentAccess,
|
|
8850
|
+
snippetAccess: user.snippetAccess,
|
|
8851
|
+
llmAccess: user.llmAccess,
|
|
8852
|
+
llmModels: user.llmModels
|
|
8853
|
+
};
|
|
8854
|
+
}
|
|
8855
|
+
|
|
7675
8856
|
// src/server/routes/admin.ts
|
|
7676
8857
|
function sendLlmUnavailable(reply) {
|
|
7677
8858
|
return reply.code(503).send({
|
|
@@ -7818,6 +8999,174 @@ async function registerAdminRoutes(app, options) {
|
|
|
7818
8999
|
}
|
|
7819
9000
|
}
|
|
7820
9001
|
});
|
|
9002
|
+
routes.route({
|
|
9003
|
+
method: "POST",
|
|
9004
|
+
url: "/admin/invited-users",
|
|
9005
|
+
schema: {
|
|
9006
|
+
body: createAdminInvitedUserBodySchema,
|
|
9007
|
+
response: {
|
|
9008
|
+
201: createAdminInvitationResponseSchema,
|
|
9009
|
+
400: errorResponseSchema,
|
|
9010
|
+
403: errorResponseSchema
|
|
9011
|
+
}
|
|
9012
|
+
},
|
|
9013
|
+
/**
|
|
9014
|
+
* Creates a user account and a single-use onboarding invitation without issuing an API token.
|
|
9015
|
+
*/
|
|
9016
|
+
handler: async (request, reply) => {
|
|
9017
|
+
try {
|
|
9018
|
+
const user = requireAuthenticatedUser(request);
|
|
9019
|
+
if (denyUnlessAllowed(reply, canUseManagementApi(user))) {
|
|
9020
|
+
return;
|
|
9021
|
+
}
|
|
9022
|
+
const input = buildAdminUserCreateInput(request.body);
|
|
9023
|
+
const llm = getLlm();
|
|
9024
|
+
const [collections, environments, snippets] = await Promise.all([
|
|
9025
|
+
db.listCollections(),
|
|
9026
|
+
db.listEnvironments(),
|
|
9027
|
+
db.listSnippets()
|
|
9028
|
+
]);
|
|
9029
|
+
const catalogs = buildAccessCatalogIds(
|
|
9030
|
+
collections,
|
|
9031
|
+
environments,
|
|
9032
|
+
snippets,
|
|
9033
|
+
llm ? listHubOfferedModels(llm).map((model) => model.id) : null
|
|
9034
|
+
);
|
|
9035
|
+
validateSubmittedAccessLists(
|
|
9036
|
+
{
|
|
9037
|
+
role: request.body.role,
|
|
9038
|
+
collectionAccess: request.body.collectionAccess,
|
|
9039
|
+
environmentAccess: request.body.environmentAccess,
|
|
9040
|
+
snippetAccess: request.body.snippetAccess,
|
|
9041
|
+
llmModels: request.body.llmModels
|
|
9042
|
+
},
|
|
9043
|
+
catalogs
|
|
9044
|
+
);
|
|
9045
|
+
const userId = randomUUID8();
|
|
9046
|
+
const expiresAt = resolveInvitationExpiresAt(request.body.expiresInHours);
|
|
9047
|
+
const { record: invitation, secret } = generateInvitation(userId, user.id, expiresAt);
|
|
9048
|
+
const created = await db.createInvitedUser(userId, input, invitation, user.id);
|
|
9049
|
+
return reply.code(201).send({
|
|
9050
|
+
user: serializeHubUser(created.user),
|
|
9051
|
+
invitation: serializeHubInvitation(created.invitation),
|
|
9052
|
+
secret
|
|
9053
|
+
});
|
|
9054
|
+
} catch (error) {
|
|
9055
|
+
if (handleValidationError(reply, error) || handleDbError(reply, error)) {
|
|
9056
|
+
return;
|
|
9057
|
+
}
|
|
9058
|
+
throw error;
|
|
9059
|
+
}
|
|
9060
|
+
}
|
|
9061
|
+
});
|
|
9062
|
+
routes.route({
|
|
9063
|
+
method: "POST",
|
|
9064
|
+
url: "/admin/users/:id/invitations",
|
|
9065
|
+
schema: {
|
|
9066
|
+
params: idParamSchema,
|
|
9067
|
+
body: createAdminInvitedUserBodySchema.pick({ expiresInHours: true }),
|
|
9068
|
+
response: {
|
|
9069
|
+
201: createAdminInvitationResponseSchema,
|
|
9070
|
+
400: errorResponseSchema,
|
|
9071
|
+
403: errorResponseSchema,
|
|
9072
|
+
404: errorResponseSchema
|
|
9073
|
+
}
|
|
9074
|
+
},
|
|
9075
|
+
/**
|
|
9076
|
+
* Issues a replacement onboarding invitation for an existing user account.
|
|
9077
|
+
*/
|
|
9078
|
+
handler: async (request, reply) => {
|
|
9079
|
+
try {
|
|
9080
|
+
const admin = requireAuthenticatedUser(request);
|
|
9081
|
+
if (denyUnlessAllowed(reply, canUseManagementApi(admin))) {
|
|
9082
|
+
return;
|
|
9083
|
+
}
|
|
9084
|
+
const existing = await db.findUserById(request.params.id);
|
|
9085
|
+
if (!existing) {
|
|
9086
|
+
return reply.code(404).send({ error: "User not found" });
|
|
9087
|
+
}
|
|
9088
|
+
if (denySystemUserTarget(reply, existing, db.getSystemUserId())) {
|
|
9089
|
+
return;
|
|
9090
|
+
}
|
|
9091
|
+
const expiresAt = resolveInvitationExpiresAt(request.body.expiresInHours);
|
|
9092
|
+
const { record: invitation, secret } = generateInvitation(existing.id, admin.id, expiresAt);
|
|
9093
|
+
await db.createInvitation(invitation, admin.id);
|
|
9094
|
+
return reply.code(201).send({
|
|
9095
|
+
user: serializeHubUser(existing),
|
|
9096
|
+
invitation: serializeHubInvitation(invitation),
|
|
9097
|
+
secret
|
|
9098
|
+
});
|
|
9099
|
+
} catch (error) {
|
|
9100
|
+
if (handleDbError(reply, error)) {
|
|
9101
|
+
return;
|
|
9102
|
+
}
|
|
9103
|
+
throw error;
|
|
9104
|
+
}
|
|
9105
|
+
}
|
|
9106
|
+
});
|
|
9107
|
+
routes.route({
|
|
9108
|
+
method: "GET",
|
|
9109
|
+
url: "/admin/invitations",
|
|
9110
|
+
schema: {
|
|
9111
|
+
response: {
|
|
9112
|
+
200: listAdminInvitationsResponseSchema,
|
|
9113
|
+
403: errorResponseSchema
|
|
9114
|
+
}
|
|
9115
|
+
},
|
|
9116
|
+
/**
|
|
9117
|
+
* Lists onboarding invitations for operator review and recovery.
|
|
9118
|
+
*/
|
|
9119
|
+
handler: async (request, reply) => {
|
|
9120
|
+
try {
|
|
9121
|
+
const user = requireAuthenticatedUser(request);
|
|
9122
|
+
if (denyUnlessAllowed(reply, canUseManagementApi(user))) {
|
|
9123
|
+
return;
|
|
9124
|
+
}
|
|
9125
|
+
const invitations = await db.listInvitations();
|
|
9126
|
+
return reply.send({
|
|
9127
|
+
invitations: invitations.map((invitation) => serializeHubInvitation(invitation))
|
|
9128
|
+
});
|
|
9129
|
+
} catch (error) {
|
|
9130
|
+
if (handleDbError(reply, error)) {
|
|
9131
|
+
return;
|
|
9132
|
+
}
|
|
9133
|
+
throw error;
|
|
9134
|
+
}
|
|
9135
|
+
}
|
|
9136
|
+
});
|
|
9137
|
+
routes.route({
|
|
9138
|
+
method: "DELETE",
|
|
9139
|
+
url: "/admin/invitations/:id",
|
|
9140
|
+
schema: {
|
|
9141
|
+
params: idParamSchema,
|
|
9142
|
+
response: {
|
|
9143
|
+
204: emptyResponseSchema,
|
|
9144
|
+
403: errorResponseSchema,
|
|
9145
|
+
404: errorResponseSchema
|
|
9146
|
+
}
|
|
9147
|
+
},
|
|
9148
|
+
/**
|
|
9149
|
+
* Revokes a pending onboarding invitation so it can no longer be redeemed.
|
|
9150
|
+
*/
|
|
9151
|
+
handler: async (request, reply) => {
|
|
9152
|
+
try {
|
|
9153
|
+
const user = requireAuthenticatedUser(request);
|
|
9154
|
+
if (denyUnlessAllowed(reply, canUseManagementApi(user))) {
|
|
9155
|
+
return;
|
|
9156
|
+
}
|
|
9157
|
+
const revoked = await db.revokeInvitation(request.params.id, user.id);
|
|
9158
|
+
if (!revoked) {
|
|
9159
|
+
return reply.code(404).send({ error: "Invitation not found or not revocable." });
|
|
9160
|
+
}
|
|
9161
|
+
return reply.code(204).send(null);
|
|
9162
|
+
} catch (error) {
|
|
9163
|
+
if (handleDbError(reply, error)) {
|
|
9164
|
+
return;
|
|
9165
|
+
}
|
|
9166
|
+
throw error;
|
|
9167
|
+
}
|
|
9168
|
+
}
|
|
9169
|
+
});
|
|
7821
9170
|
routes.route({
|
|
7822
9171
|
method: "GET",
|
|
7823
9172
|
url: "/admin/collections",
|
|
@@ -8377,7 +9726,7 @@ async function registerAdminRoutes(app, options) {
|
|
|
8377
9726
|
label: model.label,
|
|
8378
9727
|
provider: model.provider
|
|
8379
9728
|
}));
|
|
8380
|
-
return reply.send({ models });
|
|
9729
|
+
return reply.send({ models, capabilities: getHubLlmCapabilities(llm) });
|
|
8381
9730
|
}
|
|
8382
9731
|
});
|
|
8383
9732
|
routes.route({
|
|
@@ -8681,6 +10030,189 @@ async function registerAuthRoutes(app) {
|
|
|
8681
10030
|
});
|
|
8682
10031
|
}
|
|
8683
10032
|
|
|
10033
|
+
// src/db/invitationValidation.ts
|
|
10034
|
+
function assertInvitationPending(invitation, now = /* @__PURE__ */ new Date()) {
|
|
10035
|
+
if (invitation.redeemedAt) {
|
|
10036
|
+
throw new InvitationUnavailableError("redeemed");
|
|
10037
|
+
}
|
|
10038
|
+
if (invitation.revokedAt) {
|
|
10039
|
+
throw new InvitationUnavailableError("revoked");
|
|
10040
|
+
}
|
|
10041
|
+
if (invitation.expiresAt.getTime() <= now.getTime()) {
|
|
10042
|
+
throw new InvitationUnavailableError("expired");
|
|
10043
|
+
}
|
|
10044
|
+
}
|
|
10045
|
+
|
|
10046
|
+
// src/server/routes/invitationErrors.ts
|
|
10047
|
+
function handleInvitationError(reply, error) {
|
|
10048
|
+
if (!(error instanceof InvitationUnavailableError)) {
|
|
10049
|
+
return false;
|
|
10050
|
+
}
|
|
10051
|
+
const status = error.reason === "not_found" ? 404 : 410;
|
|
10052
|
+
void reply.code(status).send(errorResponseSchema.parse({ error: error.message }));
|
|
10053
|
+
return true;
|
|
10054
|
+
}
|
|
10055
|
+
|
|
10056
|
+
// src/server/routes/invitations.ts
|
|
10057
|
+
function buildInvitationThrottleKey(request) {
|
|
10058
|
+
return `${request.ip}:invitation`;
|
|
10059
|
+
}
|
|
10060
|
+
function createInvitationThrottleHook(throttleStore) {
|
|
10061
|
+
return async function invitationThrottle(request, reply) {
|
|
10062
|
+
const policy = throttleStore.getPolicy();
|
|
10063
|
+
const throttleKey = buildInvitationThrottleKey(request);
|
|
10064
|
+
try {
|
|
10065
|
+
if (await throttleStore.isBlocked(throttleKey)) {
|
|
10066
|
+
return reply.header("Retry-After", String(policy.blockSeconds)).code(429).send({ error: "Too Many Requests" });
|
|
10067
|
+
}
|
|
10068
|
+
} catch {
|
|
10069
|
+
return reply.code(503).send({ error: "Service Unavailable" });
|
|
10070
|
+
}
|
|
10071
|
+
};
|
|
10072
|
+
}
|
|
10073
|
+
async function recordInvitationFailure(throttleStore, request, reply) {
|
|
10074
|
+
const throttleKey = buildInvitationThrottleKey(request);
|
|
10075
|
+
try {
|
|
10076
|
+
await throttleStore.recordFailure(throttleKey);
|
|
10077
|
+
return false;
|
|
10078
|
+
} catch {
|
|
10079
|
+
void reply.code(503).send({ error: "Service Unavailable" });
|
|
10080
|
+
return true;
|
|
10081
|
+
}
|
|
10082
|
+
}
|
|
10083
|
+
async function resetInvitationThrottle(throttleStore, request, reply) {
|
|
10084
|
+
const throttleKey = buildInvitationThrottleKey(request);
|
|
10085
|
+
try {
|
|
10086
|
+
await throttleStore.reset(throttleKey);
|
|
10087
|
+
return false;
|
|
10088
|
+
} catch {
|
|
10089
|
+
void reply.code(503).send({ error: "Service Unavailable" });
|
|
10090
|
+
return true;
|
|
10091
|
+
}
|
|
10092
|
+
}
|
|
10093
|
+
async function registerInvitationRoutes(app, options) {
|
|
10094
|
+
const routes = app.withTypeProvider();
|
|
10095
|
+
app.addHook("onRequest", createInvitationThrottleHook(options.throttleStore));
|
|
10096
|
+
routes.route({
|
|
10097
|
+
method: "POST",
|
|
10098
|
+
url: "/auth/invitations/preview",
|
|
10099
|
+
schema: {
|
|
10100
|
+
body: invitationSecretBodySchema,
|
|
10101
|
+
response: {
|
|
10102
|
+
200: previewInvitationResponseSchema,
|
|
10103
|
+
400: errorResponseSchema,
|
|
10104
|
+
404: errorResponseSchema,
|
|
10105
|
+
410: errorResponseSchema,
|
|
10106
|
+
429: errorResponseSchema,
|
|
10107
|
+
503: errorResponseSchema
|
|
10108
|
+
}
|
|
10109
|
+
},
|
|
10110
|
+
/**
|
|
10111
|
+
* Returns invited user details for confirmation without consuming the invitation.
|
|
10112
|
+
*/
|
|
10113
|
+
handler: async (request, reply) => {
|
|
10114
|
+
try {
|
|
10115
|
+
const secret = request.body.secret;
|
|
10116
|
+
if (!isInvitationSecretFormat(secret)) {
|
|
10117
|
+
return reply.code(400).send({ error: "Invalid invitation secret format." });
|
|
10118
|
+
}
|
|
10119
|
+
const invitation = await options.db.findInvitationByCodeHash(hashInvitationSecret(secret));
|
|
10120
|
+
if (!invitation) {
|
|
10121
|
+
if (await recordInvitationFailure(options.throttleStore, request, reply)) {
|
|
10122
|
+
return;
|
|
10123
|
+
}
|
|
10124
|
+
return reply.code(404).send({ error: "Invalid or expired invitation." });
|
|
10125
|
+
}
|
|
10126
|
+
try {
|
|
10127
|
+
assertInvitationPending(invitation);
|
|
10128
|
+
} catch (error) {
|
|
10129
|
+
if (await recordInvitationFailure(options.throttleStore, request, reply)) {
|
|
10130
|
+
return;
|
|
10131
|
+
}
|
|
10132
|
+
if (handleInvitationError(reply, error)) {
|
|
10133
|
+
return;
|
|
10134
|
+
}
|
|
10135
|
+
throw error;
|
|
10136
|
+
}
|
|
10137
|
+
const user = await options.db.findUserById(invitation.userId);
|
|
10138
|
+
if (!user) {
|
|
10139
|
+
return reply.code(404).send({ error: "Invalid or expired invitation." });
|
|
10140
|
+
}
|
|
10141
|
+
if (await resetInvitationThrottle(options.throttleStore, request, reply)) {
|
|
10142
|
+
return;
|
|
10143
|
+
}
|
|
10144
|
+
return reply.send({
|
|
10145
|
+
user: serializeInvitationPreviewUser(user),
|
|
10146
|
+
expiresAt: invitation.expiresAt.toISOString()
|
|
10147
|
+
});
|
|
10148
|
+
} catch (error) {
|
|
10149
|
+
if (handleDbError(reply, error) || handleInvitationError(reply, error)) {
|
|
10150
|
+
return;
|
|
10151
|
+
}
|
|
10152
|
+
throw error;
|
|
10153
|
+
}
|
|
10154
|
+
}
|
|
10155
|
+
});
|
|
10156
|
+
routes.route({
|
|
10157
|
+
method: "POST",
|
|
10158
|
+
url: "/auth/invitations/redeem",
|
|
10159
|
+
schema: {
|
|
10160
|
+
body: redeemInvitationBodySchema,
|
|
10161
|
+
response: {
|
|
10162
|
+
201: createdApiTokenResponseSchema,
|
|
10163
|
+
400: errorResponseSchema,
|
|
10164
|
+
404: errorResponseSchema,
|
|
10165
|
+
410: errorResponseSchema,
|
|
10166
|
+
429: errorResponseSchema,
|
|
10167
|
+
503: errorResponseSchema
|
|
10168
|
+
}
|
|
10169
|
+
},
|
|
10170
|
+
/**
|
|
10171
|
+
* Consumes a pending invitation and returns a one-time permanent API token secret.
|
|
10172
|
+
*/
|
|
10173
|
+
handler: async (request, reply) => {
|
|
10174
|
+
try {
|
|
10175
|
+
const secret = request.body.secret;
|
|
10176
|
+
if (!isInvitationSecretFormat(secret)) {
|
|
10177
|
+
return reply.code(400).send({ error: "Invalid invitation secret format." });
|
|
10178
|
+
}
|
|
10179
|
+
const systemUserId = options.db.getSystemUserId();
|
|
10180
|
+
if (!systemUserId) {
|
|
10181
|
+
throw new Error("System user is not provisioned");
|
|
10182
|
+
}
|
|
10183
|
+
const tokenName = request.body.tokenName?.trim();
|
|
10184
|
+
const codeHash = hashInvitationSecret(secret);
|
|
10185
|
+
let redeemed;
|
|
10186
|
+
try {
|
|
10187
|
+
redeemed = await options.db.redeemInvitation(codeHash, tokenName ?? "", systemUserId);
|
|
10188
|
+
} catch (error) {
|
|
10189
|
+
if (error instanceof InvitationUnavailableError) {
|
|
10190
|
+
if (await recordInvitationFailure(options.throttleStore, request, reply)) {
|
|
10191
|
+
return;
|
|
10192
|
+
}
|
|
10193
|
+
}
|
|
10194
|
+
if (handleInvitationError(reply, error)) {
|
|
10195
|
+
return;
|
|
10196
|
+
}
|
|
10197
|
+
throw error;
|
|
10198
|
+
}
|
|
10199
|
+
if (await resetInvitationThrottle(options.throttleStore, request, reply)) {
|
|
10200
|
+
return;
|
|
10201
|
+
}
|
|
10202
|
+
return reply.code(201).send({
|
|
10203
|
+
token: serializeApiToken(redeemed.token),
|
|
10204
|
+
secret: redeemed.secret
|
|
10205
|
+
});
|
|
10206
|
+
} catch (error) {
|
|
10207
|
+
if (handleDbError(reply, error) || handleInvitationError(reply, error)) {
|
|
10208
|
+
return;
|
|
10209
|
+
}
|
|
10210
|
+
throw error;
|
|
10211
|
+
}
|
|
10212
|
+
}
|
|
10213
|
+
});
|
|
10214
|
+
}
|
|
10215
|
+
|
|
8684
10216
|
// src/server/routes/collections.ts
|
|
8685
10217
|
async function registerCollectionRoutes(app, db) {
|
|
8686
10218
|
const routes = app.withTypeProvider();
|
|
@@ -9301,10 +10833,10 @@ async function registerFolderRoutes(app, db) {
|
|
|
9301
10833
|
}
|
|
9302
10834
|
|
|
9303
10835
|
// src/server/routes/health.ts
|
|
9304
|
-
import { z as
|
|
9305
|
-
var healthResponseSchema =
|
|
9306
|
-
status:
|
|
9307
|
-
version:
|
|
10836
|
+
import { z as z11 } from "zod/v4";
|
|
10837
|
+
var healthResponseSchema = z11.object({
|
|
10838
|
+
status: z11.literal("ok"),
|
|
10839
|
+
version: z11.string()
|
|
9308
10840
|
});
|
|
9309
10841
|
async function registerHealthRoute(app, version2) {
|
|
9310
10842
|
app.withTypeProvider().route({
|
|
@@ -9832,6 +11364,165 @@ async function runLlmCompletion(config, input) {
|
|
|
9832
11364
|
return parseCompletionResponse(json);
|
|
9833
11365
|
}
|
|
9834
11366
|
|
|
11367
|
+
// src/server/docs/docsSearch.ts
|
|
11368
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
|
|
11369
|
+
import path3 from "path";
|
|
11370
|
+
import { create, load, search } from "@orama/orama";
|
|
11371
|
+
import OpenAI from "openai";
|
|
11372
|
+
var DOCS_EMBEDDING_MODEL = "text-embedding-3-small";
|
|
11373
|
+
var DOCS_EMBEDDING_DIMENSIONS = 1536;
|
|
11374
|
+
var DEFAULT_RESULT_LIMIT = 5;
|
|
11375
|
+
var MAX_RESULT_LIMIT = 10;
|
|
11376
|
+
var DEFAULT_SIMILARITY = 0.5;
|
|
11377
|
+
var SNIPPET_MAX_CHARS = 600;
|
|
11378
|
+
var DOCS_SEARCH_SCHEMA = {
|
|
11379
|
+
id: "string",
|
|
11380
|
+
source: "string",
|
|
11381
|
+
path: "string",
|
|
11382
|
+
url: "string",
|
|
11383
|
+
title: "string",
|
|
11384
|
+
heading: "string",
|
|
11385
|
+
content: "string",
|
|
11386
|
+
embedding: `vector[${DOCS_EMBEDDING_DIMENSIONS}]`
|
|
11387
|
+
};
|
|
11388
|
+
var cachedDb = null;
|
|
11389
|
+
var indexUnavailable = false;
|
|
11390
|
+
function getDocsSearchIndexPaths(docsConfig) {
|
|
11391
|
+
const paths = /* @__PURE__ */ new Set();
|
|
11392
|
+
if (docsConfig?.searchIndexPath) {
|
|
11393
|
+
paths.add(resolveDocsSearchIndexPath(docsConfig.searchIndexPath));
|
|
11394
|
+
}
|
|
11395
|
+
paths.add(DEFAULT_DOCS_SEARCH_INDEX_PATH);
|
|
11396
|
+
paths.add(path3.resolve(process.cwd(), "data/docsSearchIndex.json"));
|
|
11397
|
+
return [...paths];
|
|
11398
|
+
}
|
|
11399
|
+
function isDocsSearchIndexAvailable(docsConfig) {
|
|
11400
|
+
return getDocsSearchIndexPaths(docsConfig).some((candidate) => existsSync2(candidate));
|
|
11401
|
+
}
|
|
11402
|
+
function truncateSnippet(content, maxChars = SNIPPET_MAX_CHARS) {
|
|
11403
|
+
if (content.length <= maxChars) {
|
|
11404
|
+
return content;
|
|
11405
|
+
}
|
|
11406
|
+
return `${content.slice(0, maxChars)}...`;
|
|
11407
|
+
}
|
|
11408
|
+
function clampResultLimit(limit) {
|
|
11409
|
+
if (limit == null || !Number.isFinite(limit)) {
|
|
11410
|
+
return DEFAULT_RESULT_LIMIT;
|
|
11411
|
+
}
|
|
11412
|
+
return Math.min(Math.max(1, Math.floor(limit)), MAX_RESULT_LIMIT);
|
|
11413
|
+
}
|
|
11414
|
+
function loadDocsSearchIndex(docsConfig, paths) {
|
|
11415
|
+
if (cachedDb != null) {
|
|
11416
|
+
return cachedDb;
|
|
11417
|
+
}
|
|
11418
|
+
if (indexUnavailable) {
|
|
11419
|
+
throw new Error(
|
|
11420
|
+
"Documentation search index is not available. Deploy docsSearchIndex.json to the Team Hub server."
|
|
11421
|
+
);
|
|
11422
|
+
}
|
|
11423
|
+
const candidates = paths ?? getDocsSearchIndexPaths(docsConfig);
|
|
11424
|
+
const catalogPath = candidates.find((candidate) => existsSync2(candidate));
|
|
11425
|
+
if (catalogPath == null) {
|
|
11426
|
+
indexUnavailable = true;
|
|
11427
|
+
throw new Error(
|
|
11428
|
+
"Documentation search index is not available. Deploy docsSearchIndex.json to the Team Hub server."
|
|
11429
|
+
);
|
|
11430
|
+
}
|
|
11431
|
+
const raw = JSON.parse(readFileSync3(catalogPath, "utf8"));
|
|
11432
|
+
const db = create({ schema: DOCS_SEARCH_SCHEMA });
|
|
11433
|
+
load(db, raw);
|
|
11434
|
+
cachedDb = db;
|
|
11435
|
+
return db;
|
|
11436
|
+
}
|
|
11437
|
+
async function embedDocsQuery(query, apiKey) {
|
|
11438
|
+
const client = new OpenAI({ apiKey });
|
|
11439
|
+
const response = await client.embeddings.create({
|
|
11440
|
+
model: DOCS_EMBEDDING_MODEL,
|
|
11441
|
+
input: query
|
|
11442
|
+
});
|
|
11443
|
+
const embedding = response.data[0]?.embedding;
|
|
11444
|
+
if (!Array.isArray(embedding) || embedding.length !== DOCS_EMBEDDING_DIMENSIONS) {
|
|
11445
|
+
throw new Error("Unexpected embedding size from OpenAI.");
|
|
11446
|
+
}
|
|
11447
|
+
return embedding;
|
|
11448
|
+
}
|
|
11449
|
+
async function searchDocs(llmConfig, docsConfig, args) {
|
|
11450
|
+
const query = args.query?.trim();
|
|
11451
|
+
if (!query) {
|
|
11452
|
+
throw new Error("query is required.");
|
|
11453
|
+
}
|
|
11454
|
+
const apiKey = llmConfig.providers.openai?.apiKey.trim();
|
|
11455
|
+
if (!apiKey) {
|
|
11456
|
+
throw new Error(
|
|
11457
|
+
"OpenAI provider is not configured on this Team Hub. Documentation search requires an OpenAI API key."
|
|
11458
|
+
);
|
|
11459
|
+
}
|
|
11460
|
+
const db = loadDocsSearchIndex(docsConfig);
|
|
11461
|
+
const embedding = await embedDocsQuery(query, apiKey);
|
|
11462
|
+
const limit = clampResultLimit(args.limit);
|
|
11463
|
+
const results = search(db, {
|
|
11464
|
+
mode: "vector",
|
|
11465
|
+
vector: {
|
|
11466
|
+
value: embedding,
|
|
11467
|
+
property: "embedding"
|
|
11468
|
+
},
|
|
11469
|
+
similarity: DEFAULT_SIMILARITY,
|
|
11470
|
+
limit,
|
|
11471
|
+
...args.source != null ? { where: { source: args.source } } : {}
|
|
11472
|
+
});
|
|
11473
|
+
const resolved = results instanceof Promise ? await results : results;
|
|
11474
|
+
return resolved.hits.map((hit) => {
|
|
11475
|
+
const document = hit.document;
|
|
11476
|
+
return {
|
|
11477
|
+
title: document.title,
|
|
11478
|
+
heading: document.heading,
|
|
11479
|
+
url: document.url,
|
|
11480
|
+
source: document.source,
|
|
11481
|
+
path: document.path,
|
|
11482
|
+
score: hit.score,
|
|
11483
|
+
snippet: truncateSnippet(document.content)
|
|
11484
|
+
};
|
|
11485
|
+
});
|
|
11486
|
+
}
|
|
11487
|
+
|
|
11488
|
+
// src/server/llm/hubNativeTools.ts
|
|
11489
|
+
var HUB_NATIVE_TOOL_NAMES = ["search_docs"];
|
|
11490
|
+
function isHubNativeToolName(name) {
|
|
11491
|
+
return HUB_NATIVE_TOOL_NAMES.includes(name);
|
|
11492
|
+
}
|
|
11493
|
+
function canExecuteHubDocsSearch(llmConfig, docsConfig) {
|
|
11494
|
+
if (!llmConfig || !hasHubOpenAiProvider(llmConfig)) {
|
|
11495
|
+
return false;
|
|
11496
|
+
}
|
|
11497
|
+
return isDocsSearchIndexAvailable(docsConfig);
|
|
11498
|
+
}
|
|
11499
|
+
function filterClientToolsForHub(tools, llmConfig, docsConfig) {
|
|
11500
|
+
if (!tools || tools.length === 0) {
|
|
11501
|
+
return tools;
|
|
11502
|
+
}
|
|
11503
|
+
if (canExecuteHubDocsSearch(llmConfig, docsConfig)) {
|
|
11504
|
+
return tools;
|
|
11505
|
+
}
|
|
11506
|
+
const filtered = tools.filter((tool) => {
|
|
11507
|
+
const name = tool.function?.name;
|
|
11508
|
+
return name !== "search_docs";
|
|
11509
|
+
});
|
|
11510
|
+
return filtered.length > 0 ? filtered : void 0;
|
|
11511
|
+
}
|
|
11512
|
+
async function callHubNativeTool(name, args, llmConfig, docsConfig) {
|
|
11513
|
+
if (name !== "search_docs") {
|
|
11514
|
+
return JSON.stringify({ error: `Unknown hub-native tool: ${name}` });
|
|
11515
|
+
}
|
|
11516
|
+
try {
|
|
11517
|
+
const parsed = args;
|
|
11518
|
+
const hits = await searchDocs(llmConfig, docsConfig, parsed);
|
|
11519
|
+
return JSON.stringify(hits);
|
|
11520
|
+
} catch (error) {
|
|
11521
|
+
const message = error instanceof Error ? error.message : "Documentation search failed.";
|
|
11522
|
+
return JSON.stringify({ error: message });
|
|
11523
|
+
}
|
|
11524
|
+
}
|
|
11525
|
+
|
|
9835
11526
|
// src/server/llm/hubMcpToolNames.ts
|
|
9836
11527
|
var HUB_MCP_TOOL_PREFIX = "hubmcp__";
|
|
9837
11528
|
function encodeHubMcpToolName(serverIndex, toolName) {
|
|
@@ -10025,14 +11716,16 @@ function parseToolArguments(raw) {
|
|
|
10025
11716
|
return {};
|
|
10026
11717
|
}
|
|
10027
11718
|
}
|
|
10028
|
-
async function runHubChatStep(config, input, deps = {}) {
|
|
11719
|
+
async function runHubChatStep(config, input, deps = {}, docsConfig = null) {
|
|
10029
11720
|
const runCompletion = deps.runCompletion ?? runLlmCompletion;
|
|
10030
11721
|
const ensureConnections = deps.ensureConnections ?? ensureHubMcpConnections;
|
|
10031
11722
|
const listTools = deps.listTools ?? listHubMcpTools;
|
|
10032
11723
|
const callTool = deps.callTool ?? callHubMcpTool;
|
|
11724
|
+
const callNativeTool = deps.callNativeTool ?? callHubNativeTool;
|
|
10033
11725
|
await ensureConnections(config);
|
|
10034
11726
|
const hubTools = listTools();
|
|
10035
|
-
const
|
|
11727
|
+
const clientTools = filterClientToolsForHub(input.tools, config, docsConfig);
|
|
11728
|
+
const mergedTools = hubTools.length > 0 || (clientTools?.length ?? 0) > 0 ? [...hubTools, ...clientTools ?? []] : void 0;
|
|
10036
11729
|
let messages = [...input.messages];
|
|
10037
11730
|
let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
10038
11731
|
let lastContent = null;
|
|
@@ -10052,8 +11745,11 @@ async function runHubChatStep(config, input, deps = {}) {
|
|
|
10052
11745
|
usage
|
|
10053
11746
|
};
|
|
10054
11747
|
}
|
|
11748
|
+
const nativeCalls = toolCalls.filter((call) => isHubNativeToolName(call.name));
|
|
10055
11749
|
const hubCalls = toolCalls.filter((call) => isHubMcpToolName(call.name));
|
|
10056
|
-
const passthroughCalls = toolCalls.filter(
|
|
11750
|
+
const passthroughCalls = toolCalls.filter(
|
|
11751
|
+
(call) => !isHubNativeToolName(call.name) && !isHubMcpToolName(call.name)
|
|
11752
|
+
);
|
|
10057
11753
|
if (passthroughCalls.length > 0) {
|
|
10058
11754
|
return {
|
|
10059
11755
|
content: result.content,
|
|
@@ -10061,14 +11757,28 @@ async function runHubChatStep(config, input, deps = {}) {
|
|
|
10061
11757
|
usage
|
|
10062
11758
|
};
|
|
10063
11759
|
}
|
|
11760
|
+
const serverCalls = [...nativeCalls, ...hubCalls];
|
|
10064
11761
|
messages = [
|
|
10065
11762
|
...messages,
|
|
10066
11763
|
{
|
|
10067
11764
|
role: "assistant",
|
|
10068
11765
|
content: result.content,
|
|
10069
|
-
tool_calls:
|
|
11766
|
+
tool_calls: serverCalls
|
|
10070
11767
|
}
|
|
10071
11768
|
];
|
|
11769
|
+
for (const call of nativeCalls) {
|
|
11770
|
+
const toolResult = await callNativeTool(
|
|
11771
|
+
call.name,
|
|
11772
|
+
parseToolArguments(call.arguments),
|
|
11773
|
+
config,
|
|
11774
|
+
docsConfig
|
|
11775
|
+
);
|
|
11776
|
+
messages.push({
|
|
11777
|
+
role: "tool",
|
|
11778
|
+
tool_call_id: call.id,
|
|
11779
|
+
content: toolResult
|
|
11780
|
+
});
|
|
11781
|
+
}
|
|
10072
11782
|
for (const call of hubCalls) {
|
|
10073
11783
|
const toolResult = await callTool(call.name, parseToolArguments(call.arguments));
|
|
10074
11784
|
messages.push({
|
|
@@ -10127,7 +11837,8 @@ async function registerLlmRoutes(app, options) {
|
|
|
10127
11837
|
id: model.id,
|
|
10128
11838
|
label: model.label,
|
|
10129
11839
|
provider: model.provider
|
|
10130
|
-
}))
|
|
11840
|
+
})),
|
|
11841
|
+
capabilities: getHubLlmCapabilities(llm)
|
|
10131
11842
|
});
|
|
10132
11843
|
}
|
|
10133
11844
|
});
|
|
@@ -10201,12 +11912,17 @@ async function registerLlmRoutes(app, options) {
|
|
|
10201
11912
|
if (isNewTurn && isOverMonthlyLimit(totalTokens, user.llmMonthlyTokenLimit)) {
|
|
10202
11913
|
return sendMonthlyLimitExceeded(reply);
|
|
10203
11914
|
}
|
|
10204
|
-
const result = await runHubChatStep(
|
|
10205
|
-
|
|
10206
|
-
|
|
10207
|
-
|
|
10208
|
-
|
|
10209
|
-
|
|
11915
|
+
const result = await runHubChatStep(
|
|
11916
|
+
llm,
|
|
11917
|
+
{
|
|
11918
|
+
model,
|
|
11919
|
+
messages,
|
|
11920
|
+
tools,
|
|
11921
|
+
systemPrompt
|
|
11922
|
+
},
|
|
11923
|
+
{},
|
|
11924
|
+
options.getDocs()
|
|
11925
|
+
);
|
|
10210
11926
|
const catalogModel = getHubModelById(model);
|
|
10211
11927
|
if (!catalogModel) {
|
|
10212
11928
|
throw new Error(`Unknown hub model: ${model}`);
|
|
@@ -10240,10 +11956,10 @@ async function registerLlmRoutes(app, options) {
|
|
|
10240
11956
|
}
|
|
10241
11957
|
|
|
10242
11958
|
// src/server/routes/schemas/plugins.ts
|
|
10243
|
-
import { z as
|
|
10244
|
-
var pluginSourcesResponseSchema =
|
|
10245
|
-
catalogs:
|
|
10246
|
-
trusted:
|
|
11959
|
+
import { z as z12 } from "zod/v4";
|
|
11960
|
+
var pluginSourcesResponseSchema = z12.object({
|
|
11961
|
+
catalogs: z12.array(z12.string()),
|
|
11962
|
+
trusted: z12.array(z12.string())
|
|
10247
11963
|
});
|
|
10248
11964
|
|
|
10249
11965
|
// src/server/routes/plugins.ts
|
|
@@ -10337,6 +12053,10 @@ function createBearerAuthHook(db, throttleStore) {
|
|
|
10337
12053
|
// src/server/routes/index.ts
|
|
10338
12054
|
async function registerPublicRoutes(app, options) {
|
|
10339
12055
|
await registerHealthRoute(app, options.version);
|
|
12056
|
+
await registerInvitationRoutes(app, {
|
|
12057
|
+
db: options.db,
|
|
12058
|
+
throttleStore: options.throttleStore
|
|
12059
|
+
});
|
|
10340
12060
|
}
|
|
10341
12061
|
async function registerProtectedRoutes(app, options) {
|
|
10342
12062
|
registerBearerAuthDecorator(app);
|
|
@@ -10353,7 +12073,11 @@ async function registerProtectedRoutes(app, options) {
|
|
|
10353
12073
|
await registerFolderRoutes(app, options.db);
|
|
10354
12074
|
await registerRequestRoutes(app, options.db);
|
|
10355
12075
|
await registerRunResultRoutes(app, options.db);
|
|
10356
|
-
await registerLlmRoutes(app, {
|
|
12076
|
+
await registerLlmRoutes(app, {
|
|
12077
|
+
db: options.db,
|
|
12078
|
+
getLlm: options.getLlm,
|
|
12079
|
+
getDocs: options.getDocs
|
|
12080
|
+
});
|
|
10357
12081
|
await registerPluginsRoutes(app, { getPlugins: options.getPlugins });
|
|
10358
12082
|
}
|
|
10359
12083
|
async function registerRoutes(app, options) {
|
|
@@ -10388,6 +12112,7 @@ async function createServer(ctxOrConfig, options = {}) {
|
|
|
10388
12112
|
throttleStore,
|
|
10389
12113
|
getLlm: ctx ? () => ctx.getLlm() : () => legacyConfig?.llm ?? null,
|
|
10390
12114
|
getPlugins: ctx ? () => ctx.getPlugins() : () => legacyConfig?.plugins ?? null,
|
|
12115
|
+
getDocs: ctx ? () => ctx.getDocs() : () => legacyConfig?.docs ?? null,
|
|
10391
12116
|
reloadConfig: options.reloadConfig ?? (async () => ({ sections: [] }))
|
|
10392
12117
|
});
|
|
10393
12118
|
return app;
|
|
@@ -10574,7 +12299,8 @@ function createRuntimeContext(config, configPath) {
|
|
|
10574
12299
|
dbHolder: { underlying: createDatabase(config.db) },
|
|
10575
12300
|
throttleHolder: { underlying: createThrottleStore(config.redis) },
|
|
10576
12301
|
llm: config.llm,
|
|
10577
|
-
plugins: config.plugins
|
|
12302
|
+
plugins: config.plugins,
|
|
12303
|
+
docs: config.docs
|
|
10578
12304
|
};
|
|
10579
12305
|
const ctx = {
|
|
10580
12306
|
configPath: state.configPath,
|
|
@@ -10588,6 +12314,7 @@ function createRuntimeContext(config, configPath) {
|
|
|
10588
12314
|
throttleStore: createSwappableProxy(state.throttleHolder),
|
|
10589
12315
|
getLlm: () => state.llm,
|
|
10590
12316
|
getPlugins: () => state.plugins,
|
|
12317
|
+
getDocs: () => state.docs,
|
|
10591
12318
|
logger: createLogger(config.logging)
|
|
10592
12319
|
};
|
|
10593
12320
|
runtimeContextStates.set(ctx, state);
|
|
@@ -10661,6 +12388,8 @@ async function reloadRuntimeConfig(ctx) {
|
|
|
10661
12388
|
sections.push({ section: "llm", status: "reloaded" });
|
|
10662
12389
|
state.plugins = nextConfig.plugins;
|
|
10663
12390
|
sections.push({ section: "plugins", status: "reloaded" });
|
|
12391
|
+
state.docs = nextConfig.docs;
|
|
12392
|
+
sections.push({ section: "docs", status: "reloaded" });
|
|
10664
12393
|
sections.push(reloadServerSection(state, nextConfig));
|
|
10665
12394
|
return { sections };
|
|
10666
12395
|
}
|