@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.
Files changed (3) hide show
  1. package/dist/cli.js +1847 -118
  2. package/dist/cli.js.map +1 -1
  3. 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 path from "path";
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 path.isAbsolute(configPath) ? configPath : path.resolve(process.cwd(), configPath);
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 randomUUID2 } from "crypto";
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 = randomUUID2();
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 = randomUUID2();
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 = randomUUID2();
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 = randomUUID2();
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 = randomUUID2();
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 = randomUUID2();
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 = randomUUID2();
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 = randomUUID2();
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 = randomUUID2();
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 = randomUUID2();
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 randomUUID3 } from "crypto";
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 = randomUUID3();
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
- * Lists all collections ordered by name.
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 listCollections() {
3108
- const rows = await this.queryRows(
3109
- `${COLLECTION_SELECT} ORDER BY name ASC`
3110
- );
3111
- return rows.map(mapCollectionSqlRow);
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
- * Creates a new collection with the given name.
3552
+ * Persists a new onboarding invitation for an existing user account.
3115
3553
  *
3116
- * @param name - Display name for the collection.
3554
+ * @param invitation - Invitation metadata including the stored code hash.
3117
3555
  * @param actingUserId - User performing the create action.
3118
3556
  */
3119
- async createCollection(name, actingUserId) {
3120
- const trimmedName = trimRequiredName(name, "Collection name");
3121
- const id = randomUUID3();
3122
- const now = /* @__PURE__ */ new Date();
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 collections (
3563
+ `INSERT INTO user_invitations (
3125
3564
  id,
3126
- name,
3127
- variables,
3128
- headers,
3129
- auth,
3130
- pre_request_script,
3131
- post_request_script,
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
- [id, trimmedName, MYSQL_DEFAULT_AUTH_JSON, now, now, actingUserId, actingUserId]
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", "collection", id);
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
- `${COLLECTION_SELECT} WHERE id = ?`,
3598
+ `${INVITATION_SELECT} WHERE id = ? LIMIT 1`,
3142
3599
  [id]
3143
3600
  );
3144
3601
  const row = rows[0];
3145
- if (!row) {
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
- * Updates a collection's name, variables, headers, and scripts.
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 = randomUUID3();
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 = randomUUID3();
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 = randomUUID3();
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 = randomUUID3();
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 = randomUUID3();
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 = randomUUID3();
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 = randomUUID3();
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 = randomUUID3();
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 = randomUUID3();
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 randomUUID4 } from "crypto";
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 = randomUUID4();
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 = randomUUID4();
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 = randomUUID4();
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 = randomUUID4();
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 = randomUUID4();
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 = randomUUID4();
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 = randomUUID4();
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 = randomUUID4();
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 = randomUUID4();
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 = randomUUID4();
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 = randomUUID4();
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/apiTokens.ts
6269
- import { createHash, randomBytes, randomUUID as randomUUID5 } from "crypto";
6270
- var TOKEN_PREFIX = "hbk_";
6271
- function hashToken(token) {
6272
- return createHash("sha256").update(token).digest("hex");
6273
- }
6274
- function generateApiToken(userId, name) {
6275
- const secretSuffix = randomBytes(32).toString("base64url");
6276
- const secret = `${TOKEN_PREFIX}${secretSuffix}`;
6277
- const tokenPrefix = `${TOKEN_PREFIX}${secretSuffix.slice(0, 8)}`;
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: randomUUID5(),
7243
+ id: randomUUID6(),
6281
7244
  userId,
6282
- name,
6283
- tokenHash: hashToken(secret),
6284
- tokenPrefix,
6285
- createdAt,
6286
- lastUsedAt: null,
7245
+ codeHash: hashInvitationSecret(secret),
7246
+ codePrefix,
7247
+ expiresAt,
7248
+ redeemedAt: null,
6287
7249
  revokedAt: null,
6288
- createdByUserId: null,
6289
- updatedByUserId: null
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 z10 } from "zod/v4";
9305
- var healthResponseSchema = z10.object({
9306
- status: z10.literal("ok"),
9307
- version: z10.string()
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 mergedTools = hubTools.length > 0 || (input.tools?.length ?? 0) > 0 ? [...hubTools, ...input.tools ?? []] : void 0;
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((call) => !isHubMcpToolName(call.name));
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: hubCalls
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(llm, {
10205
- model,
10206
- messages,
10207
- tools,
10208
- systemPrompt
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 z11 } from "zod/v4";
10244
- var pluginSourcesResponseSchema = z11.object({
10245
- catalogs: z11.array(z11.string()),
10246
- trusted: z11.array(z11.string())
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, { db: options.db, getLlm: options.getLlm });
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
  }