@harborclient/team-hub 0.4.3 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1628 -130
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -366,7 +366,7 @@ function mergeGlobalOptions(command, options) {
|
|
|
366
366
|
}
|
|
367
367
|
|
|
368
368
|
// src/db/firestore/FirestoreDatabase.ts
|
|
369
|
-
import { randomUUID as
|
|
369
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
370
370
|
import { Firestore } from "@google-cloud/firestore";
|
|
371
371
|
|
|
372
372
|
// src/db/attribution.ts
|
|
@@ -379,9 +379,26 @@ async function resolveActingUserName(findUserById, actingUserId) {
|
|
|
379
379
|
import { randomUUID } from "crypto";
|
|
380
380
|
var BOOTSTRAP_USER_NAME = "bootstrap";
|
|
381
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
|
+
|
|
382
398
|
// src/db/firestore/const.ts
|
|
383
399
|
var USERS_COLLECTION = "users";
|
|
384
400
|
var API_TOKENS_COLLECTION = "apiTokens";
|
|
401
|
+
var INVITATIONS_COLLECTION = "invitations";
|
|
385
402
|
var COLLECTIONS_COLLECTION = "collections";
|
|
386
403
|
var ENVIRONMENTS_COLLECTION = "environments";
|
|
387
404
|
var SNIPPETS_COLLECTION = "snippets";
|
|
@@ -427,11 +444,25 @@ var firestoreConfigSchema = z2.object({
|
|
|
427
444
|
|
|
428
445
|
// src/db/firestore/utils.ts
|
|
429
446
|
function parseAuditEntityType(value) {
|
|
430
|
-
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") {
|
|
431
448
|
return value;
|
|
432
449
|
}
|
|
433
450
|
throw new Error(`Invalid audit entity type: ${value}`);
|
|
434
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
|
+
}
|
|
435
466
|
function mapFirestoreApiToken(id, data) {
|
|
436
467
|
if (!data.userId) {
|
|
437
468
|
throw new Error(`API token ${id} is missing a userId`);
|
|
@@ -644,6 +675,39 @@ function parseRunResultPayload(payload) {
|
|
|
644
675
|
};
|
|
645
676
|
}
|
|
646
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
|
+
|
|
647
711
|
// src/db/trimRequiredName.ts
|
|
648
712
|
function trimRequiredName(name, label) {
|
|
649
713
|
const trimmed = name.trim();
|
|
@@ -842,7 +906,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
842
906
|
async createUser(input, actingUserId) {
|
|
843
907
|
const trimmedName = trimRequiredName(input.name, "User name");
|
|
844
908
|
assertUserNameNotReserved(trimmedName);
|
|
845
|
-
const id =
|
|
909
|
+
const id = randomUUID3();
|
|
846
910
|
const now = /* @__PURE__ */ new Date();
|
|
847
911
|
const attributionUserId = trimmedName === SYSTEM_USER_NAME ? id : actingUserId;
|
|
848
912
|
const data = {
|
|
@@ -1146,6 +1210,205 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
1146
1210
|
async touchApiTokenLastUsed(id, when) {
|
|
1147
1211
|
await this.requireClient().collection(API_TOKENS_COLLECTION).doc(id).update({ lastUsedAt: when });
|
|
1148
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
|
+
}
|
|
1149
1412
|
/**
|
|
1150
1413
|
* Lists all collections ordered by name.
|
|
1151
1414
|
*/
|
|
@@ -1163,7 +1426,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
1163
1426
|
*/
|
|
1164
1427
|
async createCollection(name, actingUserId) {
|
|
1165
1428
|
const trimmedName = trimRequiredName(name, "Collection name");
|
|
1166
|
-
const id =
|
|
1429
|
+
const id = randomUUID3();
|
|
1167
1430
|
const now = /* @__PURE__ */ new Date();
|
|
1168
1431
|
const data = {
|
|
1169
1432
|
name: trimmedName,
|
|
@@ -1295,7 +1558,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
1295
1558
|
*/
|
|
1296
1559
|
async createEnvironment(name, actingUserId) {
|
|
1297
1560
|
const trimmedName = trimRequiredName(name, "Environment name");
|
|
1298
|
-
const id =
|
|
1561
|
+
const id = randomUUID3();
|
|
1299
1562
|
const now = /* @__PURE__ */ new Date();
|
|
1300
1563
|
const data = {
|
|
1301
1564
|
name: trimmedName,
|
|
@@ -1412,7 +1675,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
1412
1675
|
*/
|
|
1413
1676
|
async createSnippet(name, code, scope, actingUserId) {
|
|
1414
1677
|
const trimmedName = trimRequiredName(name, "Snippet name");
|
|
1415
|
-
const id =
|
|
1678
|
+
const id = randomUUID3();
|
|
1416
1679
|
const now = /* @__PURE__ */ new Date();
|
|
1417
1680
|
const existing = await this.listSnippets();
|
|
1418
1681
|
const maxOrder = existing.reduce((max, snippet) => Math.max(max, snippet.sortOrder), -1);
|
|
@@ -1606,7 +1869,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
1606
1869
|
}
|
|
1607
1870
|
const existingRequests = await this.listRequests(input.collectionId);
|
|
1608
1871
|
const maxOrder = existingRequests.filter((request) => request.folderId === folderId).reduce((max, request) => Math.max(max, request.sortOrder), -1);
|
|
1609
|
-
const id =
|
|
1872
|
+
const id = randomUUID3();
|
|
1610
1873
|
const data = {
|
|
1611
1874
|
collectionId: input.collectionId,
|
|
1612
1875
|
folderId,
|
|
@@ -1676,7 +1939,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
1676
1939
|
*/
|
|
1677
1940
|
async createFolder(collectionId, name, actingUserId) {
|
|
1678
1941
|
const trimmedName = trimRequiredName(name, "Folder name");
|
|
1679
|
-
const id =
|
|
1942
|
+
const id = randomUUID3();
|
|
1680
1943
|
const now = /* @__PURE__ */ new Date();
|
|
1681
1944
|
const existingFolders = await this.listFolders(collectionId);
|
|
1682
1945
|
const maxOrder = existingFolders.reduce((max, folder) => Math.max(max, folder.sortOrder), -1);
|
|
@@ -1938,7 +2201,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
1938
2201
|
async createRunResult(input, actingUserId) {
|
|
1939
2202
|
const metadata = parseRunResultPayload(input.payload);
|
|
1940
2203
|
const label = input.label?.trim() || buildDefaultRunResultLabel(metadata);
|
|
1941
|
-
const id =
|
|
2204
|
+
const id = randomUUID3();
|
|
1942
2205
|
const now = /* @__PURE__ */ new Date();
|
|
1943
2206
|
const data = {
|
|
1944
2207
|
kind: metadata.kind,
|
|
@@ -1987,7 +2250,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
1987
2250
|
* @param input - Usage details for one successful completion step.
|
|
1988
2251
|
*/
|
|
1989
2252
|
async createLlmUsageLog(input) {
|
|
1990
|
-
const id =
|
|
2253
|
+
const id = randomUUID3();
|
|
1991
2254
|
const now = /* @__PURE__ */ new Date();
|
|
1992
2255
|
const data = {
|
|
1993
2256
|
userId: input.userId,
|
|
@@ -2043,7 +2306,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
2043
2306
|
return;
|
|
2044
2307
|
}
|
|
2045
2308
|
const input = createSystemUserInput();
|
|
2046
|
-
const id =
|
|
2309
|
+
const id = randomUUID3();
|
|
2047
2310
|
const now = /* @__PURE__ */ new Date();
|
|
2048
2311
|
const trimmedName = trimRequiredName(input.name, "User name");
|
|
2049
2312
|
const data = {
|
|
@@ -2077,7 +2340,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
2077
2340
|
(userId) => this.findUserById(userId),
|
|
2078
2341
|
actingUserId
|
|
2079
2342
|
);
|
|
2080
|
-
const id =
|
|
2343
|
+
const id = randomUUID3();
|
|
2081
2344
|
const now = /* @__PURE__ */ new Date();
|
|
2082
2345
|
const data = {
|
|
2083
2346
|
userId: actingUserId,
|
|
@@ -2105,7 +2368,7 @@ var FirestoreDatabase = class _FirestoreDatabase {
|
|
|
2105
2368
|
};
|
|
2106
2369
|
|
|
2107
2370
|
// src/db/mysql/MysqlDatabase.ts
|
|
2108
|
-
import { randomUUID as
|
|
2371
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
2109
2372
|
import mysql from "mysql2/promise";
|
|
2110
2373
|
|
|
2111
2374
|
// src/db/apiTokenRows.ts
|
|
@@ -2127,6 +2390,49 @@ function mapApiTokenSqlRow(row) {
|
|
|
2127
2390
|
};
|
|
2128
2391
|
}
|
|
2129
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
|
+
|
|
2130
2436
|
// src/db/auditLogRows.ts
|
|
2131
2437
|
function parseAuditAction(value) {
|
|
2132
2438
|
if (value === "create" || value === "update" || value === "delete" || value === "reorder" || value === "move") {
|
|
@@ -2135,7 +2441,7 @@ function parseAuditAction(value) {
|
|
|
2135
2441
|
throw new Error(`Invalid audit action: ${value}`);
|
|
2136
2442
|
}
|
|
2137
2443
|
function parseAuditEntityType2(value) {
|
|
2138
|
-
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") {
|
|
2139
2445
|
return value;
|
|
2140
2446
|
}
|
|
2141
2447
|
throw new Error(`Invalid audit entity type: ${value}`);
|
|
@@ -2541,6 +2847,25 @@ CREATE TABLE IF NOT EXISTS run_results (
|
|
|
2541
2847
|
INDEX run_results_created_idx (created_at)
|
|
2542
2848
|
)
|
|
2543
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();
|
|
2544
2869
|
var MYSQL_MIGRATIONS = [
|
|
2545
2870
|
USERS_MIGRATION_SQL,
|
|
2546
2871
|
API_TOKENS_MIGRATION_SQL,
|
|
@@ -2567,7 +2892,8 @@ var MYSQL_MIGRATIONS = [
|
|
|
2567
2892
|
ENVIRONMENTS_DELETION_LOCKED_MIGRATION_SQL,
|
|
2568
2893
|
USERS_SNIPPET_ACCESS_MIGRATION_SQL,
|
|
2569
2894
|
USERS_SNIPPET_ACCESS_BACKFILL_SQL,
|
|
2570
|
-
RUN_RESULTS_MIGRATION_SQL
|
|
2895
|
+
RUN_RESULTS_MIGRATION_SQL,
|
|
2896
|
+
USER_INVITATIONS_MIGRATION_SQL
|
|
2571
2897
|
];
|
|
2572
2898
|
|
|
2573
2899
|
// src/db/mysql/schemas.ts
|
|
@@ -2800,7 +3126,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
2800
3126
|
async createUser(input, actingUserId) {
|
|
2801
3127
|
const trimmedName = trimRequiredName(input.name, "User name");
|
|
2802
3128
|
assertUserNameNotReserved(trimmedName);
|
|
2803
|
-
const id =
|
|
3129
|
+
const id = randomUUID4();
|
|
2804
3130
|
const now = /* @__PURE__ */ new Date();
|
|
2805
3131
|
const attributionUserId = trimmedName === SYSTEM_USER_NAME ? id : actingUserId;
|
|
2806
3132
|
await this.executeStatement(
|
|
@@ -3128,65 +3454,355 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
3128
3454
|
await this.executeStatement(`UPDATE api_tokens SET last_used_at = ? WHERE id = ?`, [when, id]);
|
|
3129
3455
|
}
|
|
3130
3456
|
/**
|
|
3131
|
-
*
|
|
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.
|
|
3132
3463
|
*/
|
|
3133
|
-
async
|
|
3134
|
-
const
|
|
3135
|
-
|
|
3136
|
-
);
|
|
3137
|
-
|
|
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
|
+
}
|
|
3138
3550
|
}
|
|
3139
3551
|
/**
|
|
3140
|
-
*
|
|
3552
|
+
* Persists a new onboarding invitation for an existing user account.
|
|
3141
3553
|
*
|
|
3142
|
-
* @param
|
|
3554
|
+
* @param invitation - Invitation metadata including the stored code hash.
|
|
3143
3555
|
* @param actingUserId - User performing the create action.
|
|
3144
3556
|
*/
|
|
3145
|
-
async
|
|
3146
|
-
const
|
|
3147
|
-
|
|
3148
|
-
|
|
3557
|
+
async createInvitation(invitation, actingUserId) {
|
|
3558
|
+
const user = await this.findUserById(invitation.userId);
|
|
3559
|
+
if (!user) {
|
|
3560
|
+
throw new Error("User not found");
|
|
3561
|
+
}
|
|
3149
3562
|
await this.executeStatement(
|
|
3150
|
-
`INSERT INTO
|
|
3563
|
+
`INSERT INTO user_invitations (
|
|
3151
3564
|
id,
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3565
|
+
user_id,
|
|
3566
|
+
code_hash,
|
|
3567
|
+
code_prefix,
|
|
3568
|
+
expires_at,
|
|
3569
|
+
redeemed_at,
|
|
3570
|
+
revoked_at,
|
|
3158
3571
|
created_at,
|
|
3159
|
-
updated_at,
|
|
3160
3572
|
created_by_user_id,
|
|
3161
3573
|
updated_by_user_id
|
|
3162
|
-
) VALUES (?, ?,
|
|
3163
|
-
[
|
|
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
|
+
]
|
|
3164
3587
|
);
|
|
3165
|
-
await this.recordAuditEntry(actingUserId, "create", "
|
|
3588
|
+
await this.recordAuditEntry(actingUserId, "create", "invitation", invitation.id);
|
|
3589
|
+
return invitation;
|
|
3590
|
+
}
|
|
3591
|
+
/**
|
|
3592
|
+
* Finds an invitation by stable identifier.
|
|
3593
|
+
*
|
|
3594
|
+
* @param id - Invitation identifier to look up.
|
|
3595
|
+
*/
|
|
3596
|
+
async findInvitationById(id) {
|
|
3166
3597
|
const rows = await this.queryRows(
|
|
3167
|
-
`${
|
|
3598
|
+
`${INVITATION_SELECT} WHERE id = ? LIMIT 1`,
|
|
3168
3599
|
[id]
|
|
3169
3600
|
);
|
|
3170
3601
|
const row = rows[0];
|
|
3171
|
-
|
|
3172
|
-
throw new Error("Collection not found after insert");
|
|
3173
|
-
}
|
|
3174
|
-
return mapCollectionSqlRow(row);
|
|
3602
|
+
return row ? mapInvitationSqlRow(row) : null;
|
|
3175
3603
|
}
|
|
3176
3604
|
/**
|
|
3177
|
-
*
|
|
3605
|
+
* Finds an invitation by the sha256 hash of its secret.
|
|
3178
3606
|
*
|
|
3179
|
-
* @param
|
|
3607
|
+
* @param codeHash - sha256 hex digest of the invitation secret.
|
|
3180
3608
|
*/
|
|
3181
|
-
async
|
|
3182
|
-
const
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
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.
|
|
3794
|
+
*
|
|
3795
|
+
* @param actingUserId - User performing the update action.
|
|
3796
|
+
*/
|
|
3797
|
+
async updateCollection(id, name, variables, headers, preRequestScript, postRequestScript, auth, actingUserId) {
|
|
3798
|
+
const trimmedName = trimRequiredName(name, "Collection name");
|
|
3799
|
+
const updatedAt = /* @__PURE__ */ new Date();
|
|
3800
|
+
const result = await this.executeStatement(
|
|
3801
|
+
`UPDATE collections
|
|
3802
|
+
SET name = ?,
|
|
3803
|
+
variables = ?,
|
|
3804
|
+
headers = ?,
|
|
3805
|
+
auth = ?,
|
|
3190
3806
|
pre_request_script = ?,
|
|
3191
3807
|
post_request_script = ?,
|
|
3192
3808
|
updated_at = ?,
|
|
@@ -3289,7 +3905,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
3289
3905
|
*/
|
|
3290
3906
|
async createEnvironment(name, actingUserId) {
|
|
3291
3907
|
const trimmedName = trimRequiredName(name, "Environment name");
|
|
3292
|
-
const id =
|
|
3908
|
+
const id = randomUUID4();
|
|
3293
3909
|
const now = /* @__PURE__ */ new Date();
|
|
3294
3910
|
await this.executeStatement(
|
|
3295
3911
|
`INSERT INTO environments (
|
|
@@ -3418,7 +4034,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
3418
4034
|
*/
|
|
3419
4035
|
async createSnippet(name, code, scope, actingUserId) {
|
|
3420
4036
|
const trimmedName = trimRequiredName(name, "Snippet name");
|
|
3421
|
-
const id =
|
|
4037
|
+
const id = randomUUID4();
|
|
3422
4038
|
const now = /* @__PURE__ */ new Date();
|
|
3423
4039
|
const maxRows = await this.queryRows(
|
|
3424
4040
|
"SELECT COALESCE(MAX(sort_order), -1) AS max_order FROM snippets"
|
|
@@ -3641,7 +4257,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
3641
4257
|
[input.collectionId, folderId, folderId]
|
|
3642
4258
|
);
|
|
3643
4259
|
const maxOrder = maxRows[0]?.max_order ?? -1;
|
|
3644
|
-
const id =
|
|
4260
|
+
const id = randomUUID4();
|
|
3645
4261
|
await this.executeStatement(
|
|
3646
4262
|
`INSERT INTO requests (
|
|
3647
4263
|
id,
|
|
@@ -3741,7 +4357,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
3741
4357
|
*/
|
|
3742
4358
|
async createFolder(collectionId, name, actingUserId) {
|
|
3743
4359
|
const trimmedName = trimRequiredName(name, "Folder name");
|
|
3744
|
-
const id =
|
|
4360
|
+
const id = randomUUID4();
|
|
3745
4361
|
const now = /* @__PURE__ */ new Date();
|
|
3746
4362
|
const maxRows = await this.queryRows(
|
|
3747
4363
|
"SELECT COALESCE(MAX(sort_order), -1) AS max_order FROM folders WHERE collection_id = ?",
|
|
@@ -3995,7 +4611,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
3995
4611
|
async addLlmUsage(userId, period, promptTokens, completionTokens) {
|
|
3996
4612
|
const totalDelta = promptTokens + completionTokens;
|
|
3997
4613
|
const now = /* @__PURE__ */ new Date();
|
|
3998
|
-
const id =
|
|
4614
|
+
const id = randomUUID4();
|
|
3999
4615
|
await this.executeStatement(
|
|
4000
4616
|
`INSERT INTO llm_usage (
|
|
4001
4617
|
id,
|
|
@@ -4025,7 +4641,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
4025
4641
|
* @param input - Usage details for one successful completion step.
|
|
4026
4642
|
*/
|
|
4027
4643
|
async createLlmUsageLog(input) {
|
|
4028
|
-
const id =
|
|
4644
|
+
const id = randomUUID4();
|
|
4029
4645
|
const now = /* @__PURE__ */ new Date();
|
|
4030
4646
|
await this.executeStatement(
|
|
4031
4647
|
`INSERT INTO llm_usage_log (
|
|
@@ -4103,7 +4719,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
4103
4719
|
async createRunResult(input, actingUserId) {
|
|
4104
4720
|
const metadata = parseRunResultPayload(input.payload);
|
|
4105
4721
|
const label = input.label?.trim() || buildDefaultRunResultLabel(metadata);
|
|
4106
|
-
const id =
|
|
4722
|
+
const id = randomUUID4();
|
|
4107
4723
|
const now = /* @__PURE__ */ new Date();
|
|
4108
4724
|
await this.executeStatement(
|
|
4109
4725
|
`INSERT INTO run_results (
|
|
@@ -4175,7 +4791,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
4175
4791
|
return;
|
|
4176
4792
|
}
|
|
4177
4793
|
const input = createSystemUserInput();
|
|
4178
|
-
const id =
|
|
4794
|
+
const id = randomUUID4();
|
|
4179
4795
|
const now = /* @__PURE__ */ new Date();
|
|
4180
4796
|
const trimmedName = trimRequiredName(input.name, "User name");
|
|
4181
4797
|
await this.executeStatement(
|
|
@@ -4223,7 +4839,7 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
4223
4839
|
*/
|
|
4224
4840
|
async recordAuditEntry(actingUserId, action, entityType, entityId, metadata) {
|
|
4225
4841
|
const userName = await resolveActingUserName(this.findUserById.bind(this), actingUserId);
|
|
4226
|
-
const id =
|
|
4842
|
+
const id = randomUUID4();
|
|
4227
4843
|
const now = /* @__PURE__ */ new Date();
|
|
4228
4844
|
await this.executeStatement(
|
|
4229
4845
|
`INSERT INTO audit_log (
|
|
@@ -4285,9 +4901,24 @@ var MysqlDatabase = class _MysqlDatabase {
|
|
|
4285
4901
|
};
|
|
4286
4902
|
|
|
4287
4903
|
// src/db/postgres/PostgresDatabase.ts
|
|
4288
|
-
import { randomUUID as
|
|
4904
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
4289
4905
|
import pg from "pg";
|
|
4290
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
|
+
|
|
4291
4922
|
// src/db/postgres/migrations.ts
|
|
4292
4923
|
var API_TOKENS_MIGRATION_SQL2 = `
|
|
4293
4924
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
@@ -4527,6 +5158,22 @@ CREATE TABLE IF NOT EXISTS run_results (
|
|
|
4527
5158
|
);
|
|
4528
5159
|
CREATE INDEX IF NOT EXISTS run_results_created_idx ON run_results (created_at DESC);
|
|
4529
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();
|
|
4530
5177
|
var POSTGRES_MIGRATIONS = [
|
|
4531
5178
|
USERS_MIGRATION_SQL2,
|
|
4532
5179
|
API_TOKENS_MIGRATION_SQL2,
|
|
@@ -4553,7 +5200,8 @@ var POSTGRES_MIGRATIONS = [
|
|
|
4553
5200
|
ENVIRONMENTS_DELETION_LOCKED_MIGRATION_SQL2,
|
|
4554
5201
|
USERS_SNIPPET_ACCESS_MIGRATION_SQL2,
|
|
4555
5202
|
USERS_SNIPPET_ACCESS_BACKFILL_SQL2,
|
|
4556
|
-
RUN_RESULTS_MIGRATION_SQL2
|
|
5203
|
+
RUN_RESULTS_MIGRATION_SQL2,
|
|
5204
|
+
USER_INVITATIONS_MIGRATION_SQL2
|
|
4557
5205
|
];
|
|
4558
5206
|
|
|
4559
5207
|
// src/db/postgres/schemas.ts
|
|
@@ -4711,7 +5359,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
4711
5359
|
async createUser(input, actingUserId) {
|
|
4712
5360
|
const trimmedName = trimRequiredName(input.name, "User name");
|
|
4713
5361
|
assertUserNameNotReserved(trimmedName);
|
|
4714
|
-
const id =
|
|
5362
|
+
const id = randomUUID5();
|
|
4715
5363
|
const now = /* @__PURE__ */ new Date();
|
|
4716
5364
|
const result = await this.query(
|
|
4717
5365
|
`INSERT INTO users (
|
|
@@ -5029,6 +5677,286 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5029
5677
|
async touchApiTokenLastUsed(id, when) {
|
|
5030
5678
|
await this.query(`UPDATE api_tokens SET last_used_at = $2 WHERE id = $1`, [id, when]);
|
|
5031
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
|
+
}
|
|
5032
5960
|
/**
|
|
5033
5961
|
* Lists all collections ordered by name.
|
|
5034
5962
|
*/
|
|
@@ -5044,7 +5972,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5044
5972
|
*/
|
|
5045
5973
|
async createCollection(name, actingUserId) {
|
|
5046
5974
|
const trimmedName = trimRequiredName(name, "Collection name");
|
|
5047
|
-
const id =
|
|
5975
|
+
const id = randomUUID5();
|
|
5048
5976
|
const now = /* @__PURE__ */ new Date();
|
|
5049
5977
|
const result = await this.query(
|
|
5050
5978
|
`INSERT INTO collections (
|
|
@@ -5179,7 +6107,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5179
6107
|
*/
|
|
5180
6108
|
async createEnvironment(name, actingUserId) {
|
|
5181
6109
|
const trimmedName = trimRequiredName(name, "Environment name");
|
|
5182
|
-
const id =
|
|
6110
|
+
const id = randomUUID5();
|
|
5183
6111
|
const now = /* @__PURE__ */ new Date();
|
|
5184
6112
|
const result = await this.query(
|
|
5185
6113
|
`INSERT INTO environments (
|
|
@@ -5302,7 +6230,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5302
6230
|
*/
|
|
5303
6231
|
async createSnippet(name, code, scope, actingUserId) {
|
|
5304
6232
|
const trimmedName = trimRequiredName(name, "Snippet name");
|
|
5305
|
-
const id =
|
|
6233
|
+
const id = randomUUID5();
|
|
5306
6234
|
const now = /* @__PURE__ */ new Date();
|
|
5307
6235
|
const maxResult = await this.query(
|
|
5308
6236
|
"SELECT COALESCE(MAX(sort_order), -1) AS max_order FROM snippets"
|
|
@@ -5508,7 +6436,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5508
6436
|
[input.collectionId, folderId]
|
|
5509
6437
|
);
|
|
5510
6438
|
const maxOrder = maxResult.rows[0]?.max_order ?? -1;
|
|
5511
|
-
const id =
|
|
6439
|
+
const id = randomUUID5();
|
|
5512
6440
|
const result = await this.query(
|
|
5513
6441
|
`INSERT INTO requests (
|
|
5514
6442
|
id,
|
|
@@ -5602,7 +6530,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5602
6530
|
*/
|
|
5603
6531
|
async createFolder(collectionId, name, actingUserId) {
|
|
5604
6532
|
const trimmedName = trimRequiredName(name, "Folder name");
|
|
5605
|
-
const id =
|
|
6533
|
+
const id = randomUUID5();
|
|
5606
6534
|
const now = /* @__PURE__ */ new Date();
|
|
5607
6535
|
const maxResult = await this.query(
|
|
5608
6536
|
"SELECT COALESCE(MAX(sort_order), -1) AS max_order FROM folders WHERE collection_id = $1",
|
|
@@ -5849,7 +6777,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5849
6777
|
async addLlmUsage(userId, period, promptTokens, completionTokens) {
|
|
5850
6778
|
const totalDelta = promptTokens + completionTokens;
|
|
5851
6779
|
const now = /* @__PURE__ */ new Date();
|
|
5852
|
-
const id =
|
|
6780
|
+
const id = randomUUID5();
|
|
5853
6781
|
const result = await this.query(
|
|
5854
6782
|
`INSERT INTO llm_usage (
|
|
5855
6783
|
id,
|
|
@@ -5880,7 +6808,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5880
6808
|
* @param input - Usage details for one successful completion step.
|
|
5881
6809
|
*/
|
|
5882
6810
|
async createLlmUsageLog(input) {
|
|
5883
|
-
const id =
|
|
6811
|
+
const id = randomUUID5();
|
|
5884
6812
|
const now = /* @__PURE__ */ new Date();
|
|
5885
6813
|
const result = await this.query(
|
|
5886
6814
|
`INSERT INTO llm_usage_log (
|
|
@@ -5955,7 +6883,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
5955
6883
|
async createRunResult(input, actingUserId) {
|
|
5956
6884
|
const metadata = parseRunResultPayload(input.payload);
|
|
5957
6885
|
const label = input.label?.trim() || buildDefaultRunResultLabel(metadata);
|
|
5958
|
-
const id =
|
|
6886
|
+
const id = randomUUID5();
|
|
5959
6887
|
const now = /* @__PURE__ */ new Date();
|
|
5960
6888
|
const result = await this.query(
|
|
5961
6889
|
`INSERT INTO run_results (
|
|
@@ -6035,7 +6963,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
6035
6963
|
this.systemUserId = existing.id;
|
|
6036
6964
|
return;
|
|
6037
6965
|
}
|
|
6038
|
-
const id =
|
|
6966
|
+
const id = randomUUID5();
|
|
6039
6967
|
const now = /* @__PURE__ */ new Date();
|
|
6040
6968
|
const input = createSystemUserInput();
|
|
6041
6969
|
await this.query(
|
|
@@ -6086,7 +7014,7 @@ var PostgresDatabase = class _PostgresDatabase {
|
|
|
6086
7014
|
(userId) => this.findUserById(userId),
|
|
6087
7015
|
actingUserId
|
|
6088
7016
|
);
|
|
6089
|
-
const id =
|
|
7017
|
+
const id = randomUUID5();
|
|
6090
7018
|
const now = /* @__PURE__ */ new Date();
|
|
6091
7019
|
await this.query(
|
|
6092
7020
|
`INSERT INTO audit_log (
|
|
@@ -6290,39 +7218,41 @@ function registerMigrateCommand(program, handler = migrateCommand) {
|
|
|
6290
7218
|
|
|
6291
7219
|
// src/cli/userCommand.ts
|
|
6292
7220
|
import { InvalidArgumentError } from "commander";
|
|
7221
|
+
import { randomUUID as randomUUID7 } from "crypto";
|
|
6293
7222
|
|
|
6294
|
-
// src/server/auth/
|
|
6295
|
-
import { createHash, randomBytes, randomUUID as
|
|
6296
|
-
var
|
|
6297
|
-
|
|
6298
|
-
|
|
6299
|
-
|
|
6300
|
-
|
|
6301
|
-
|
|
6302
|
-
|
|
6303
|
-
|
|
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)}`;
|
|
6304
7241
|
const createdAt = /* @__PURE__ */ new Date();
|
|
6305
7242
|
const record = {
|
|
6306
|
-
id:
|
|
7243
|
+
id: randomUUID6(),
|
|
6307
7244
|
userId,
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
|
|
6311
|
-
|
|
6312
|
-
lastUsedAt: null,
|
|
7245
|
+
codeHash: hashInvitationSecret(secret),
|
|
7246
|
+
codePrefix,
|
|
7247
|
+
expiresAt,
|
|
7248
|
+
redeemedAt: null,
|
|
6313
7249
|
revokedAt: null,
|
|
6314
|
-
|
|
6315
|
-
|
|
7250
|
+
createdAt,
|
|
7251
|
+
createdByUserId: actingUserId,
|
|
7252
|
+
updatedByUserId: actingUserId
|
|
6316
7253
|
};
|
|
6317
7254
|
return { record, secret };
|
|
6318
7255
|
}
|
|
6319
|
-
function extractBearer(headerValue) {
|
|
6320
|
-
if (!headerValue) {
|
|
6321
|
-
return null;
|
|
6322
|
-
}
|
|
6323
|
-
const match = /^Bearer\s+(\S+)$/i.exec(headerValue.trim());
|
|
6324
|
-
return match?.[1] ?? null;
|
|
6325
|
-
}
|
|
6326
7256
|
|
|
6327
7257
|
// src/server/admin/userValidation.ts
|
|
6328
7258
|
var ValidationError = class extends Error {
|
|
@@ -6578,6 +7508,13 @@ function parseMonthlyTokenLimit(value) {
|
|
|
6578
7508
|
}
|
|
6579
7509
|
return parsed;
|
|
6580
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
|
+
}
|
|
6581
7518
|
function printUser(user, usage) {
|
|
6582
7519
|
console.log(`- id: ${user.id}`);
|
|
6583
7520
|
console.log(` name: ${user.name}`);
|
|
@@ -6683,6 +7620,100 @@ async function userCreateCommand(options) {
|
|
|
6683
7620
|
console.log("");
|
|
6684
7621
|
printCreatedApiToken(user, record, secret);
|
|
6685
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
|
+
}
|
|
6686
7717
|
async function userListCommand(options) {
|
|
6687
7718
|
const config = loadServerConfig(options.config);
|
|
6688
7719
|
const db = createDatabase(config.db);
|
|
@@ -6855,6 +7886,48 @@ async function userTokenRevokeCommand(options) {
|
|
|
6855
7886
|
}
|
|
6856
7887
|
function registerUserCommand(program, handlers = {}) {
|
|
6857
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
|
+
);
|
|
6858
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(
|
|
6859
7932
|
"--collection-access <id>",
|
|
6860
7933
|
"Collection id or * (repeatable)",
|
|
@@ -7026,6 +8099,9 @@ function readPackageVersion() {
|
|
|
7026
8099
|
return pkg.version;
|
|
7027
8100
|
}
|
|
7028
8101
|
|
|
8102
|
+
// src/server/routes/admin.ts
|
|
8103
|
+
import { randomUUID as randomUUID8 } from "crypto";
|
|
8104
|
+
|
|
7029
8105
|
// src/server/auth/accessControl.ts
|
|
7030
8106
|
function isAdmin(user) {
|
|
7031
8107
|
return user.role === "admin";
|
|
@@ -7679,34 +8755,101 @@ var reloadConfigSectionResultSchema = z9.object({
|
|
|
7679
8755
|
status: z9.enum(["reloaded", "unchanged", "failed", "restart-required"]),
|
|
7680
8756
|
error: z9.string().optional()
|
|
7681
8757
|
});
|
|
7682
|
-
var reloadConfigResponseSchema = z9.object({
|
|
7683
|
-
sections: z9.array(reloadConfigSectionResultSchema),
|
|
7684
|
-
fatalError: z9.string().optional()
|
|
8758
|
+
var reloadConfigResponseSchema = z9.object({
|
|
8759
|
+
sections: z9.array(reloadConfigSectionResultSchema),
|
|
8760
|
+
fatalError: z9.string().optional()
|
|
8761
|
+
});
|
|
8762
|
+
function serializeHubUser(user) {
|
|
8763
|
+
return {
|
|
8764
|
+
id: user.id,
|
|
8765
|
+
name: user.name,
|
|
8766
|
+
role: user.role,
|
|
8767
|
+
collectionAccess: user.collectionAccess,
|
|
8768
|
+
environmentAccess: user.environmentAccess,
|
|
8769
|
+
snippetAccess: user.snippetAccess,
|
|
8770
|
+
llmAccess: user.llmAccess,
|
|
8771
|
+
llmModels: user.llmModels,
|
|
8772
|
+
llmMonthlyTokenLimit: user.llmMonthlyTokenLimit,
|
|
8773
|
+
createdAt: user.createdAt.toISOString(),
|
|
8774
|
+
updatedAt: user.updatedAt.toISOString()
|
|
8775
|
+
};
|
|
8776
|
+
}
|
|
8777
|
+
function serializeApiToken(token) {
|
|
8778
|
+
return {
|
|
8779
|
+
id: token.id,
|
|
8780
|
+
userId: token.userId,
|
|
8781
|
+
name: token.name,
|
|
8782
|
+
tokenPrefix: token.tokenPrefix,
|
|
8783
|
+
createdAt: token.createdAt.toISOString(),
|
|
8784
|
+
lastUsedAt: token.lastUsedAt ? token.lastUsedAt.toISOString() : null,
|
|
8785
|
+
revokedAt: token.revokedAt ? token.revokedAt.toISOString() : null
|
|
8786
|
+
};
|
|
8787
|
+
}
|
|
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
|
|
7685
8831
|
});
|
|
7686
|
-
function
|
|
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) {
|
|
7687
8845
|
return {
|
|
7688
|
-
id: user.id,
|
|
7689
8846
|
name: user.name,
|
|
7690
8847
|
role: user.role,
|
|
7691
8848
|
collectionAccess: user.collectionAccess,
|
|
7692
8849
|
environmentAccess: user.environmentAccess,
|
|
7693
8850
|
snippetAccess: user.snippetAccess,
|
|
7694
8851
|
llmAccess: user.llmAccess,
|
|
7695
|
-
llmModels: user.llmModels
|
|
7696
|
-
llmMonthlyTokenLimit: user.llmMonthlyTokenLimit,
|
|
7697
|
-
createdAt: user.createdAt.toISOString(),
|
|
7698
|
-
updatedAt: user.updatedAt.toISOString()
|
|
7699
|
-
};
|
|
7700
|
-
}
|
|
7701
|
-
function serializeApiToken(token) {
|
|
7702
|
-
return {
|
|
7703
|
-
id: token.id,
|
|
7704
|
-
userId: token.userId,
|
|
7705
|
-
name: token.name,
|
|
7706
|
-
tokenPrefix: token.tokenPrefix,
|
|
7707
|
-
createdAt: token.createdAt.toISOString(),
|
|
7708
|
-
lastUsedAt: token.lastUsedAt ? token.lastUsedAt.toISOString() : null,
|
|
7709
|
-
revokedAt: token.revokedAt ? token.revokedAt.toISOString() : null
|
|
8852
|
+
llmModels: user.llmModels
|
|
7710
8853
|
};
|
|
7711
8854
|
}
|
|
7712
8855
|
|
|
@@ -7856,6 +8999,174 @@ async function registerAdminRoutes(app, options) {
|
|
|
7856
8999
|
}
|
|
7857
9000
|
}
|
|
7858
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
|
+
});
|
|
7859
9170
|
routes.route({
|
|
7860
9171
|
method: "GET",
|
|
7861
9172
|
url: "/admin/collections",
|
|
@@ -8719,6 +10030,189 @@ async function registerAuthRoutes(app) {
|
|
|
8719
10030
|
});
|
|
8720
10031
|
}
|
|
8721
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
|
+
|
|
8722
10216
|
// src/server/routes/collections.ts
|
|
8723
10217
|
async function registerCollectionRoutes(app, db) {
|
|
8724
10218
|
const routes = app.withTypeProvider();
|
|
@@ -9339,10 +10833,10 @@ async function registerFolderRoutes(app, db) {
|
|
|
9339
10833
|
}
|
|
9340
10834
|
|
|
9341
10835
|
// src/server/routes/health.ts
|
|
9342
|
-
import { z as
|
|
9343
|
-
var healthResponseSchema =
|
|
9344
|
-
status:
|
|
9345
|
-
version:
|
|
10836
|
+
import { z as z11 } from "zod/v4";
|
|
10837
|
+
var healthResponseSchema = z11.object({
|
|
10838
|
+
status: z11.literal("ok"),
|
|
10839
|
+
version: z11.string()
|
|
9346
10840
|
});
|
|
9347
10841
|
async function registerHealthRoute(app, version2) {
|
|
9348
10842
|
app.withTypeProvider().route({
|
|
@@ -10462,10 +11956,10 @@ async function registerLlmRoutes(app, options) {
|
|
|
10462
11956
|
}
|
|
10463
11957
|
|
|
10464
11958
|
// src/server/routes/schemas/plugins.ts
|
|
10465
|
-
import { z as
|
|
10466
|
-
var pluginSourcesResponseSchema =
|
|
10467
|
-
catalogs:
|
|
10468
|
-
trusted:
|
|
11959
|
+
import { z as z12 } from "zod/v4";
|
|
11960
|
+
var pluginSourcesResponseSchema = z12.object({
|
|
11961
|
+
catalogs: z12.array(z12.string()),
|
|
11962
|
+
trusted: z12.array(z12.string())
|
|
10469
11963
|
});
|
|
10470
11964
|
|
|
10471
11965
|
// src/server/routes/plugins.ts
|
|
@@ -10559,6 +12053,10 @@ function createBearerAuthHook(db, throttleStore) {
|
|
|
10559
12053
|
// src/server/routes/index.ts
|
|
10560
12054
|
async function registerPublicRoutes(app, options) {
|
|
10561
12055
|
await registerHealthRoute(app, options.version);
|
|
12056
|
+
await registerInvitationRoutes(app, {
|
|
12057
|
+
db: options.db,
|
|
12058
|
+
throttleStore: options.throttleStore
|
|
12059
|
+
});
|
|
10562
12060
|
}
|
|
10563
12061
|
async function registerProtectedRoutes(app, options) {
|
|
10564
12062
|
registerBearerAuthDecorator(app);
|