@harborclient/team-hub 0.2.1 → 0.2.3

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 CHANGED
@@ -42,6 +42,40 @@ function normalizeLlmConfig(section) {
42
42
  };
43
43
  }
44
44
 
45
+ // src/config/loggingConfig.ts
46
+ var DEFAULT_LOGGING_CONFIG = {
47
+ level: "info",
48
+ file: null,
49
+ console: true
50
+ };
51
+ function normalizeLoggingConfig(section) {
52
+ return {
53
+ level: section?.level ?? DEFAULT_LOGGING_CONFIG.level,
54
+ file: section?.file ?? DEFAULT_LOGGING_CONFIG.file,
55
+ console: section?.console ?? DEFAULT_LOGGING_CONFIG.console
56
+ };
57
+ }
58
+
59
+ // src/config/pluginsConfig.ts
60
+ function dedupeUrls(urls) {
61
+ const seen = /* @__PURE__ */ new Set();
62
+ const deduped = [];
63
+ for (const url of urls) {
64
+ if (seen.has(url)) {
65
+ continue;
66
+ }
67
+ seen.add(url);
68
+ deduped.push(url);
69
+ }
70
+ return deduped;
71
+ }
72
+ function normalizePluginsConfig(section) {
73
+ return {
74
+ catalogs: dedupeUrls(section.catalogs ?? []),
75
+ trusted: dedupeUrls(section.trusted ?? [])
76
+ };
77
+ }
78
+
45
79
  // src/config/serverConfig.schema.ts
46
80
  import { z } from "zod/v4";
47
81
  var portSchema = z.union([
@@ -93,11 +127,23 @@ var llmSectionSchema = z.object({
93
127
  ),
94
128
  models: z.array(z.string().trim().min(1)).optional()
95
129
  });
130
+ var pluginsSectionSchema = z.object({
131
+ catalogs: z.array(z.string().trim().url()).optional(),
132
+ trusted: z.array(z.string().trim().url()).optional()
133
+ });
134
+ var logLevelSchema = z.enum(["debug", "info", "warn", "error"]);
135
+ var loggingSectionSchema = z.object({
136
+ level: logLevelSchema.optional(),
137
+ file: z.string().trim().min(1).optional(),
138
+ console: z.boolean().optional()
139
+ });
96
140
  var serverConfigDocumentSchema = z.object({
97
141
  server: serverSectionSchema,
98
142
  db: dbSectionSchema,
99
143
  redis: redisSectionSchema,
100
- llm: llmSectionSchema.optional()
144
+ llm: llmSectionSchema.optional(),
145
+ plugins: pluginsSectionSchema.optional(),
146
+ logging: loggingSectionSchema.optional()
101
147
  });
102
148
 
103
149
  // src/config/serverConfig.ts
@@ -189,12 +235,30 @@ function parseServerConfig(document) {
189
235
  }
190
236
  llm = normalizeLlmConfig(parsedLlmSection.data);
191
237
  }
238
+ let plugins = null;
239
+ if (root.plugins !== void 0) {
240
+ const parsedPluginsSection = pluginsSectionSchema.safeParse(root.plugins);
241
+ if (!parsedPluginsSection.success) {
242
+ throw new ConfigError(formatZodError(parsedPluginsSection.error));
243
+ }
244
+ plugins = normalizePluginsConfig(parsedPluginsSection.data);
245
+ }
246
+ let logging = DEFAULT_LOGGING_CONFIG;
247
+ if (root.logging !== void 0) {
248
+ const parsedLoggingSection = loggingSectionSchema.safeParse(root.logging);
249
+ if (!parsedLoggingSection.success) {
250
+ throw new ConfigError(formatZodError(parsedLoggingSection.error));
251
+ }
252
+ logging = normalizeLoggingConfig(parsedLoggingSection.data);
253
+ }
192
254
  return {
193
255
  port: parsedDocument.data.server.port,
194
256
  host: parsedDocument.data.server.host,
195
257
  db: parsedDbSection.data,
196
258
  redis: parsedRedisSection.data,
197
- llm
259
+ llm,
260
+ plugins,
261
+ logging
198
262
  };
199
263
  }
200
264
  function loadServerConfig(configPath) {
@@ -367,7 +431,8 @@ function mapFirestoreCollection(id, data) {
367
431
  createdAt: data.createdAt,
368
432
  updatedAt: data.updatedAt ?? data.createdAt,
369
433
  createdByUserId: data.createdByUserId ?? null,
370
- updatedByUserId: data.updatedByUserId ?? null
434
+ updatedByUserId: data.updatedByUserId ?? null,
435
+ deletionLocked: data.deletionLocked ?? false
371
436
  };
372
437
  }
373
438
  function mapFirestoreEnvironment(id, data) {
@@ -378,7 +443,8 @@ function mapFirestoreEnvironment(id, data) {
378
443
  createdAt: data.createdAt,
379
444
  updatedAt: data.updatedAt ?? data.createdAt,
380
445
  createdByUserId: data.createdByUserId ?? null,
381
- updatedByUserId: data.updatedByUserId ?? null
446
+ updatedByUserId: data.updatedByUserId ?? null,
447
+ deletionLocked: data.deletionLocked ?? false
382
448
  };
383
449
  }
384
450
  function mapFirestoreFolder(id, data) {
@@ -924,7 +990,8 @@ var FirestoreDatabase = class _FirestoreDatabase {
924
990
  createdAt: now,
925
991
  updatedAt: now,
926
992
  createdByUserId: actingUserId,
927
- updatedByUserId: actingUserId
993
+ updatedByUserId: actingUserId,
994
+ deletionLocked: false
928
995
  };
929
996
  await this.requireClient().collection(COLLECTIONS_COLLECTION).doc(id).set(data);
930
997
  await this.recordAuditEntry(actingUserId, "create", "collection", id);
@@ -986,6 +1053,46 @@ var FirestoreDatabase = class _FirestoreDatabase {
986
1053
  ];
987
1054
  await this.commitBatchedDeletes(refs);
988
1055
  }
1056
+ /**
1057
+ * Finds a collection by stable identifier.
1058
+ *
1059
+ * @param id - Collection ID to look up.
1060
+ */
1061
+ async findCollectionById(id) {
1062
+ const snapshot = await this.requireClient().collection(COLLECTIONS_COLLECTION).doc(id).get();
1063
+ if (!snapshot.exists) {
1064
+ return null;
1065
+ }
1066
+ return mapFirestoreCollection(id, snapshot.data());
1067
+ }
1068
+ /**
1069
+ * Updates whether non-admin users may delete a collection.
1070
+ *
1071
+ * @param id - Collection ID to update.
1072
+ * @param deletionLocked - When true, user-role tokens cannot delete the collection.
1073
+ * @param actingUserId - Admin user performing the update.
1074
+ */
1075
+ async setCollectionDeletionLocked(id, deletionLocked, actingUserId) {
1076
+ const docRef = this.requireClient().collection(COLLECTIONS_COLLECTION).doc(id);
1077
+ const snapshot = await docRef.get();
1078
+ if (!snapshot.exists) {
1079
+ throw new Error("Collection not found");
1080
+ }
1081
+ const updatedAt = /* @__PURE__ */ new Date();
1082
+ await docRef.update({
1083
+ deletionLocked,
1084
+ updatedAt,
1085
+ updatedByUserId: actingUserId
1086
+ });
1087
+ await this.recordAuditEntry(actingUserId, "update", "collection", id);
1088
+ const existing = snapshot.data();
1089
+ return mapFirestoreCollection(id, {
1090
+ ...existing,
1091
+ deletionLocked,
1092
+ updatedAt,
1093
+ updatedByUserId: actingUserId
1094
+ });
1095
+ }
989
1096
  /**
990
1097
  * Lists all environments ordered by name.
991
1098
  */
@@ -1011,7 +1118,8 @@ var FirestoreDatabase = class _FirestoreDatabase {
1011
1118
  createdAt: now,
1012
1119
  updatedAt: now,
1013
1120
  createdByUserId: actingUserId,
1014
- updatedByUserId: actingUserId
1121
+ updatedByUserId: actingUserId,
1122
+ deletionLocked: false
1015
1123
  };
1016
1124
  await this.requireClient().collection(ENVIRONMENTS_COLLECTION).doc(id).set(data);
1017
1125
  await this.recordAuditEntry(actingUserId, "create", "environment", id);
@@ -1057,6 +1165,46 @@ var FirestoreDatabase = class _FirestoreDatabase {
1057
1165
  await this.recordAuditEntry(actingUserId, "delete", "environment", id);
1058
1166
  await this.requireClient().collection(ENVIRONMENTS_COLLECTION).doc(id).delete();
1059
1167
  }
1168
+ /**
1169
+ * Finds an environment by stable identifier.
1170
+ *
1171
+ * @param id - Environment ID to look up.
1172
+ */
1173
+ async findEnvironmentById(id) {
1174
+ const snapshot = await this.requireClient().collection(ENVIRONMENTS_COLLECTION).doc(id).get();
1175
+ if (!snapshot.exists) {
1176
+ return null;
1177
+ }
1178
+ return mapFirestoreEnvironment(id, snapshot.data());
1179
+ }
1180
+ /**
1181
+ * Updates whether non-admin users may delete an environment.
1182
+ *
1183
+ * @param id - Environment ID to update.
1184
+ * @param deletionLocked - When true, user-role tokens cannot delete the environment.
1185
+ * @param actingUserId - Admin user performing the update.
1186
+ */
1187
+ async setEnvironmentDeletionLocked(id, deletionLocked, actingUserId) {
1188
+ const docRef = this.requireClient().collection(ENVIRONMENTS_COLLECTION).doc(id);
1189
+ const snapshot = await docRef.get();
1190
+ if (!snapshot.exists) {
1191
+ throw new Error("Environment not found");
1192
+ }
1193
+ const updatedAt = /* @__PURE__ */ new Date();
1194
+ await docRef.update({
1195
+ deletionLocked,
1196
+ updatedAt,
1197
+ updatedByUserId: actingUserId
1198
+ });
1199
+ await this.recordAuditEntry(actingUserId, "update", "environment", id);
1200
+ const existing = snapshot.data();
1201
+ return mapFirestoreEnvironment(id, {
1202
+ ...existing,
1203
+ deletionLocked,
1204
+ updatedAt,
1205
+ updatedByUserId: actingUserId
1206
+ });
1207
+ }
1060
1208
  /**
1061
1209
  * Lists all saved requests in a collection.
1062
1210
  *
@@ -1667,7 +1815,8 @@ function mapCollectionSqlRow(row) {
1667
1815
  createdAt: row.created_at,
1668
1816
  updatedAt: row.updated_at ?? row.created_at,
1669
1817
  createdByUserId: row.created_by_user_id ?? null,
1670
- updatedByUserId: row.updated_by_user_id ?? null
1818
+ updatedByUserId: row.updated_by_user_id ?? null,
1819
+ deletionLocked: Boolean(row.deletion_locked)
1671
1820
  };
1672
1821
  }
1673
1822
  function mapEnvironmentSqlRow(row) {
@@ -1678,7 +1827,8 @@ function mapEnvironmentSqlRow(row) {
1678
1827
  createdAt: row.created_at,
1679
1828
  updatedAt: row.updated_at ?? row.created_at,
1680
1829
  createdByUserId: row.created_by_user_id ?? null,
1681
- updatedByUserId: row.updated_by_user_id ?? null
1830
+ updatedByUserId: row.updated_by_user_id ?? null,
1831
+ deletionLocked: Boolean(row.deletion_locked)
1682
1832
  };
1683
1833
  }
1684
1834
  function mapFolderSqlRow(row) {
@@ -1921,6 +2071,14 @@ CREATE TABLE IF NOT EXISTS llm_usage_log (
1921
2071
  )
1922
2072
  `.trim();
1923
2073
  var MYSQL_DEFAULT_AUTH_JSON = DEFAULT_AUTH_JSON;
2074
+ var COLLECTIONS_DELETION_LOCKED_MIGRATION_SQL = `
2075
+ ALTER TABLE collections
2076
+ ADD COLUMN IF NOT EXISTS deletion_locked TINYINT(1) NOT NULL DEFAULT 0;
2077
+ `.trim();
2078
+ var ENVIRONMENTS_DELETION_LOCKED_MIGRATION_SQL = `
2079
+ ALTER TABLE environments
2080
+ ADD COLUMN IF NOT EXISTS deletion_locked TINYINT(1) NOT NULL DEFAULT 0;
2081
+ `.trim();
1924
2082
  var MYSQL_MIGRATIONS = [
1925
2083
  USERS_MIGRATION_SQL,
1926
2084
  API_TOKENS_MIGRATION_SQL,
@@ -1941,7 +2099,9 @@ var MYSQL_MIGRATIONS = [
1941
2099
  FOLDERS_BACKFILL_UPDATED_AT_SQL,
1942
2100
  USERS_LLM_MIGRATION_SQL,
1943
2101
  LLM_USAGE_MIGRATION_SQL,
1944
- LLM_USAGE_LOG_MIGRATION_SQL
2102
+ LLM_USAGE_LOG_MIGRATION_SQL,
2103
+ COLLECTIONS_DELETION_LOCKED_MIGRATION_SQL,
2104
+ ENVIRONMENTS_DELETION_LOCKED_MIGRATION_SQL
1945
2105
  ];
1946
2106
 
1947
2107
  // src/db/mysql/schemas.ts
@@ -1998,8 +2158,8 @@ function serializeAccessList(access) {
1998
2158
  return JSON.stringify(access);
1999
2159
  }
2000
2160
  var USER_SELECT_COLUMNS = `id, name, role, collection_access, environment_access, llm_access, llm_models, llm_monthly_token_limit, created_at, updated_at, created_by_user_id, updated_by_user_id`;
2001
- var COLLECTION_SELECT_COLUMNS = `id, name, variables, headers, auth, pre_request_script, post_request_script, created_at, updated_at, created_by_user_id, updated_by_user_id`;
2002
- var ENVIRONMENT_SELECT_COLUMNS = `id, name, variables, created_at, updated_at, created_by_user_id, updated_by_user_id`;
2161
+ var COLLECTION_SELECT_COLUMNS = `id, name, variables, headers, auth, pre_request_script, post_request_script, created_at, updated_at, created_by_user_id, updated_by_user_id, deletion_locked`;
2162
+ var ENVIRONMENT_SELECT_COLUMNS = `id, name, variables, created_at, updated_at, created_by_user_id, updated_by_user_id, deletion_locked`;
2003
2163
  var FOLDER_SELECT_COLUMNS = `id, collection_id, name, sort_order, created_at, updated_at, created_by_user_id, updated_by_user_id`;
2004
2164
  var REQUEST_SELECT_COLUMNS = `id, collection_id, folder_id, name, method, url, headers, params, auth, body, body_type, pre_request_script, post_request_script, comment, sort_order, created_at, updated_at, created_by_user_id, updated_by_user_id`;
2005
2165
  var API_TOKEN_SELECT_COLUMNS = `id, user_id, name, token_hash, token_prefix, created_at, last_used_at, revoked_at, created_by_user_id, updated_by_user_id`;
@@ -2591,6 +2751,50 @@ var MysqlDatabase = class _MysqlDatabase {
2591
2751
  await this.recordAuditEntry(actingUserId, "delete", "collection", id);
2592
2752
  await this.executeStatement("DELETE FROM collections WHERE id = ?", [id]);
2593
2753
  }
2754
+ /**
2755
+ * Finds a collection by stable identifier.
2756
+ *
2757
+ * @param id - Collection ID to look up.
2758
+ */
2759
+ async findCollectionById(id) {
2760
+ const rows = await this.queryRows(
2761
+ `${COLLECTION_SELECT} WHERE id = ?`,
2762
+ [id]
2763
+ );
2764
+ const row = rows[0];
2765
+ return row ? mapCollectionSqlRow(row) : null;
2766
+ }
2767
+ /**
2768
+ * Updates whether non-admin users may delete a collection.
2769
+ *
2770
+ * @param id - Collection ID to update.
2771
+ * @param deletionLocked - When true, user-role tokens cannot delete the collection.
2772
+ * @param actingUserId - Admin user performing the update.
2773
+ */
2774
+ async setCollectionDeletionLocked(id, deletionLocked, actingUserId) {
2775
+ const updatedAt = /* @__PURE__ */ new Date();
2776
+ const result = await this.executeStatement(
2777
+ `UPDATE collections
2778
+ SET deletion_locked = ?,
2779
+ updated_at = ?,
2780
+ updated_by_user_id = ?
2781
+ WHERE id = ?`,
2782
+ [deletionLocked ? 1 : 0, updatedAt, actingUserId, id]
2783
+ );
2784
+ if ((result.affectedRows ?? 0) === 0) {
2785
+ throw new Error("Collection not found");
2786
+ }
2787
+ await this.recordAuditEntry(actingUserId, "update", "collection", id);
2788
+ const rows = await this.queryRows(
2789
+ `${COLLECTION_SELECT} WHERE id = ?`,
2790
+ [id]
2791
+ );
2792
+ const row = rows[0];
2793
+ if (!row) {
2794
+ throw new Error("Collection not found");
2795
+ }
2796
+ return mapCollectionSqlRow(row);
2797
+ }
2594
2798
  /**
2595
2799
  * Lists all environments ordered by name.
2596
2800
  */
@@ -2674,6 +2878,50 @@ var MysqlDatabase = class _MysqlDatabase {
2674
2878
  await this.recordAuditEntry(actingUserId, "delete", "environment", id);
2675
2879
  await this.executeStatement("DELETE FROM environments WHERE id = ?", [id]);
2676
2880
  }
2881
+ /**
2882
+ * Finds an environment by stable identifier.
2883
+ *
2884
+ * @param id - Environment ID to look up.
2885
+ */
2886
+ async findEnvironmentById(id) {
2887
+ const rows = await this.queryRows(
2888
+ `${ENVIRONMENT_SELECT} WHERE id = ?`,
2889
+ [id]
2890
+ );
2891
+ const row = rows[0];
2892
+ return row ? mapEnvironmentSqlRow(row) : null;
2893
+ }
2894
+ /**
2895
+ * Updates whether non-admin users may delete an environment.
2896
+ *
2897
+ * @param id - Environment ID to update.
2898
+ * @param deletionLocked - When true, user-role tokens cannot delete the environment.
2899
+ * @param actingUserId - Admin user performing the update.
2900
+ */
2901
+ async setEnvironmentDeletionLocked(id, deletionLocked, actingUserId) {
2902
+ const updatedAt = /* @__PURE__ */ new Date();
2903
+ const result = await this.executeStatement(
2904
+ `UPDATE environments
2905
+ SET deletion_locked = ?,
2906
+ updated_at = ?,
2907
+ updated_by_user_id = ?
2908
+ WHERE id = ?`,
2909
+ [deletionLocked ? 1 : 0, updatedAt, actingUserId, id]
2910
+ );
2911
+ if ((result.affectedRows ?? 0) === 0) {
2912
+ throw new Error("Environment not found");
2913
+ }
2914
+ await this.recordAuditEntry(actingUserId, "update", "environment", id);
2915
+ const rows = await this.queryRows(
2916
+ `${ENVIRONMENT_SELECT} WHERE id = ?`,
2917
+ [id]
2918
+ );
2919
+ const row = rows[0];
2920
+ if (!row) {
2921
+ throw new Error("Environment not found");
2922
+ }
2923
+ return mapEnvironmentSqlRow(row);
2924
+ }
2677
2925
  /**
2678
2926
  * Lists all saved requests in a collection.
2679
2927
  *
@@ -3526,6 +3774,14 @@ CREATE INDEX IF NOT EXISTS llm_usage_log_user_created_at_idx ON llm_usage_log (u
3526
3774
 
3527
3775
  CREATE INDEX IF NOT EXISTS llm_usage_log_period_idx ON llm_usage_log (period);
3528
3776
  `.trim();
3777
+ var COLLECTIONS_DELETION_LOCKED_MIGRATION_SQL2 = `
3778
+ ALTER TABLE collections
3779
+ ADD COLUMN IF NOT EXISTS deletion_locked BOOLEAN NOT NULL DEFAULT FALSE;
3780
+ `.trim();
3781
+ var ENVIRONMENTS_DELETION_LOCKED_MIGRATION_SQL2 = `
3782
+ ALTER TABLE environments
3783
+ ADD COLUMN IF NOT EXISTS deletion_locked BOOLEAN NOT NULL DEFAULT FALSE;
3784
+ `.trim();
3529
3785
  var POSTGRES_MIGRATIONS = [
3530
3786
  USERS_MIGRATION_SQL2,
3531
3787
  API_TOKENS_MIGRATION_SQL2,
@@ -3546,7 +3802,9 @@ var POSTGRES_MIGRATIONS = [
3546
3802
  FOLDERS_BACKFILL_UPDATED_AT_SQL2,
3547
3803
  USERS_LLM_MIGRATION_SQL2,
3548
3804
  LLM_USAGE_MIGRATION_SQL2,
3549
- LLM_USAGE_LOG_MIGRATION_SQL2
3805
+ LLM_USAGE_LOG_MIGRATION_SQL2,
3806
+ COLLECTIONS_DELETION_LOCKED_MIGRATION_SQL2,
3807
+ ENVIRONMENTS_DELETION_LOCKED_MIGRATION_SQL2
3550
3808
  ];
3551
3809
 
3552
3810
  // src/db/postgres/schemas.ts
@@ -4108,6 +4366,46 @@ var PostgresDatabase = class _PostgresDatabase {
4108
4366
  await this.recordAuditEntry(actingUserId, "delete", "collection", id);
4109
4367
  await this.query("DELETE FROM collections WHERE id = $1", [id]);
4110
4368
  }
4369
+ /**
4370
+ * Finds a collection by stable identifier.
4371
+ *
4372
+ * @param id - Collection ID to look up.
4373
+ */
4374
+ async findCollectionById(id) {
4375
+ const result = await this.query(`${COLLECTION_SELECT2} WHERE id = $1`, [id]);
4376
+ const row = result.rows[0];
4377
+ return row ? mapCollectionSqlRow(row) : null;
4378
+ }
4379
+ /**
4380
+ * Updates whether non-admin users may delete a collection.
4381
+ *
4382
+ * @param id - Collection ID to update.
4383
+ * @param deletionLocked - When true, user-role tokens cannot delete the collection.
4384
+ * @param actingUserId - Admin user performing the update.
4385
+ */
4386
+ async setCollectionDeletionLocked(id, deletionLocked, actingUserId) {
4387
+ const updatedAt = /* @__PURE__ */ new Date();
4388
+ const result = await this.query(
4389
+ `UPDATE collections
4390
+ SET deletion_locked = $1,
4391
+ updated_at = $2,
4392
+ updated_by_user_id = $3
4393
+ WHERE id = $4`,
4394
+ [deletionLocked, updatedAt, actingUserId, id]
4395
+ );
4396
+ if ((result.rowCount ?? 0) === 0) {
4397
+ throw new Error("Collection not found");
4398
+ }
4399
+ await this.recordAuditEntry(actingUserId, "update", "collection", id);
4400
+ const selectResult = await this.query(`${COLLECTION_SELECT2} WHERE id = $1`, [
4401
+ id
4402
+ ]);
4403
+ const row = selectResult.rows[0];
4404
+ if (!row) {
4405
+ throw new Error("Collection not found");
4406
+ }
4407
+ return mapCollectionSqlRow(row);
4408
+ }
4111
4409
  /**
4112
4410
  * Lists all environments ordered by name.
4113
4411
  */
@@ -4186,6 +4484,47 @@ var PostgresDatabase = class _PostgresDatabase {
4186
4484
  await this.recordAuditEntry(actingUserId, "delete", "environment", id);
4187
4485
  await this.query("DELETE FROM environments WHERE id = $1", [id]);
4188
4486
  }
4487
+ /**
4488
+ * Finds an environment by stable identifier.
4489
+ *
4490
+ * @param id - Environment ID to look up.
4491
+ */
4492
+ async findEnvironmentById(id) {
4493
+ const result = await this.query(`${ENVIRONMENT_SELECT2} WHERE id = $1`, [id]);
4494
+ const row = result.rows[0];
4495
+ return row ? mapEnvironmentSqlRow(row) : null;
4496
+ }
4497
+ /**
4498
+ * Updates whether non-admin users may delete an environment.
4499
+ *
4500
+ * @param id - Environment ID to update.
4501
+ * @param deletionLocked - When true, user-role tokens cannot delete the environment.
4502
+ * @param actingUserId - Admin user performing the update.
4503
+ */
4504
+ async setEnvironmentDeletionLocked(id, deletionLocked, actingUserId) {
4505
+ const updatedAt = /* @__PURE__ */ new Date();
4506
+ const result = await this.query(
4507
+ `UPDATE environments
4508
+ SET deletion_locked = $1,
4509
+ updated_at = $2,
4510
+ updated_by_user_id = $3
4511
+ WHERE id = $4`,
4512
+ [deletionLocked, updatedAt, actingUserId, id]
4513
+ );
4514
+ if ((result.rowCount ?? 0) === 0) {
4515
+ throw new Error("Environment not found");
4516
+ }
4517
+ await this.recordAuditEntry(actingUserId, "update", "environment", id);
4518
+ const selectResult = await this.query(
4519
+ `${ENVIRONMENT_SELECT2} WHERE id = $1`,
4520
+ [id]
4521
+ );
4522
+ const row = selectResult.rows[0];
4523
+ if (!row) {
4524
+ throw new Error("Environment not found");
4525
+ }
4526
+ return mapEnvironmentSqlRow(row);
4527
+ }
4189
4528
  /**
4190
4529
  * Lists all saved requests in a collection.
4191
4530
  *
@@ -5618,6 +5957,48 @@ import {
5618
5957
  validatorCompiler
5619
5958
  } from "fastify-type-provider-zod";
5620
5959
 
5960
+ // src/server/logging/httpLogging.ts
5961
+ function registerHttpLogging(app, logger) {
5962
+ app.addHook("onRequest", async (request) => {
5963
+ logger.debug("request", {
5964
+ reqId: request.id,
5965
+ method: request.method,
5966
+ url: request.url,
5967
+ ip: request.ip
5968
+ });
5969
+ });
5970
+ app.addHook("onError", async (request, reply, error) => {
5971
+ logger.error("request error", {
5972
+ reqId: request.id,
5973
+ method: request.method,
5974
+ url: request.url,
5975
+ statusCode: reply.statusCode,
5976
+ message: error.message,
5977
+ stack: error.stack
5978
+ });
5979
+ });
5980
+ }
5981
+
5982
+ // src/server/logging/logger.ts
5983
+ import winston from "winston";
5984
+ function createLogger(config) {
5985
+ const transports = [];
5986
+ if (config.console) {
5987
+ transports.push(new winston.transports.Console());
5988
+ }
5989
+ if (config.file) {
5990
+ transports.push(new winston.transports.File({ filename: config.file }));
5991
+ }
5992
+ if (transports.length === 0) {
5993
+ transports.push(new winston.transports.Console({ silent: true }));
5994
+ }
5995
+ return winston.createLogger({
5996
+ level: config.level,
5997
+ format: winston.format.combine(winston.format.timestamp(), winston.format.json()),
5998
+ transports
5999
+ });
6000
+ }
6001
+
5621
6002
  // src/packageVersion.ts
5622
6003
  import { readFileSync as readFileSync2 } from "fs";
5623
6004
  function readPackageVersion() {
@@ -5638,6 +6019,9 @@ function canUseDataApi(user) {
5638
6019
  function canListCollections(user) {
5639
6020
  return canUseDataApi(user) || canUseManagementApi(user);
5640
6021
  }
6022
+ function canListEnvironments(user) {
6023
+ return canUseDataApi(user) || canUseManagementApi(user);
6024
+ }
5641
6025
  function hasWildcardAccess(access) {
5642
6026
  return access.includes("*");
5643
6027
  }
@@ -5666,20 +6050,14 @@ function canCreateEnvironment(user) {
5666
6050
  return user.role === "user" && hasWildcardAccess(user.environmentAccess);
5667
6051
  }
5668
6052
  function filterAccessibleCollections(user, collections) {
5669
- if (user.role === "admin") {
5670
- return [];
5671
- }
5672
- if (hasWildcardAccess(user.collectionAccess)) {
6053
+ if (user.role === "admin" || hasWildcardAccess(user.collectionAccess)) {
5673
6054
  return collections;
5674
6055
  }
5675
6056
  const allowed = new Set(user.collectionAccess);
5676
6057
  return collections.filter((collection) => allowed.has(collection.id));
5677
6058
  }
5678
6059
  function filterAccessibleEnvironments(user, environments) {
5679
- if (user.role === "admin") {
5680
- return [];
5681
- }
5682
- if (hasWildcardAccess(user.environmentAccess)) {
6060
+ if (user.role === "admin" || hasWildcardAccess(user.environmentAccess)) {
5683
6061
  return environments;
5684
6062
  }
5685
6063
  const allowed = new Set(user.environmentAccess);
@@ -5704,6 +6082,17 @@ function isOverMonthlyLimit(totalTokens, limit) {
5704
6082
  return totalTokens >= limit;
5705
6083
  }
5706
6084
 
6085
+ // src/db/deletionLockedError.ts
6086
+ var DeletionLockedError = class extends Error {
6087
+ /**
6088
+ * @param entityType - Human-readable entity kind shown in the error message.
6089
+ */
6090
+ constructor(entityType) {
6091
+ super(`Deletion is locked for this ${entityType}.`);
6092
+ this.name = "DeletionLockedError";
6093
+ }
6094
+ };
6095
+
5707
6096
  // src/server/routes/schemas/common.ts
5708
6097
  import { z as z5 } from "zod/v4";
5709
6098
  var errorResponseSchema = z5.object({
@@ -5780,6 +6169,10 @@ function handleDbError(reply, error) {
5780
6169
  void reply.code(400).send(errorResponseSchema.parse({ error: error.message }));
5781
6170
  return true;
5782
6171
  }
6172
+ if (error instanceof DeletionLockedError) {
6173
+ void reply.code(403).send(errorResponseSchema.parse({ error: error.message }));
6174
+ return true;
6175
+ }
5783
6176
  if (isDuplicateUserNameDbError(error)) {
5784
6177
  void reply.code(400).send(errorResponseSchema.parse({ error: DUPLICATE_USER_NAME_MESSAGE }));
5785
6178
  return true;
@@ -5884,7 +6277,19 @@ var llmUsageSummaryResponseSchema = z7.object({
5884
6277
  // src/server/routes/schemas/admin.ts
5885
6278
  var adminResourceOptionSchema = z8.object({
5886
6279
  id: z8.string(),
5887
- name: z8.string()
6280
+ name: z8.string(),
6281
+ deletionLocked: z8.boolean()
6282
+ });
6283
+ var adminEntityConfigSchema = z8.object({
6284
+ id: z8.string(),
6285
+ name: z8.string(),
6286
+ deletionLocked: z8.boolean()
6287
+ });
6288
+ var updateAdminCollectionBodySchema = z8.object({
6289
+ deletionLocked: z8.boolean()
6290
+ });
6291
+ var updateAdminEnvironmentBodySchema = z8.object({
6292
+ deletionLocked: z8.boolean()
5888
6293
  });
5889
6294
  var listAdminCollectionsResponseSchema = z8.object({
5890
6295
  collections: z8.array(adminResourceOptionSchema)
@@ -5953,6 +6358,15 @@ var createdApiTokenResponseSchema = z8.object({
5953
6358
  var listAdminTokensResponseSchema = z8.object({
5954
6359
  tokens: z8.array(hubApiTokenRecordSchema)
5955
6360
  });
6361
+ var reloadConfigSectionResultSchema = z8.object({
6362
+ section: z8.enum(["db", "redis", "llm", "plugins", "server"]),
6363
+ status: z8.enum(["reloaded", "unchanged", "failed", "restart-required"]),
6364
+ error: z8.string().optional()
6365
+ });
6366
+ var reloadConfigResponseSchema = z8.object({
6367
+ sections: z8.array(reloadConfigSectionResultSchema),
6368
+ fatalError: z8.string().optional()
6369
+ });
5956
6370
  function serializeHubUser(user) {
5957
6371
  return {
5958
6372
  id: user.id,
@@ -5992,7 +6406,8 @@ var collectionRecordSchema = z9.object({
5992
6406
  createdAt: timestampSchema,
5993
6407
  updatedAt: timestampSchema,
5994
6408
  createdByUserId: z9.string().nullable(),
5995
- updatedByUserId: z9.string().nullable()
6409
+ updatedByUserId: z9.string().nullable(),
6410
+ deletionLocked: z9.boolean()
5996
6411
  });
5997
6412
  var environmentRecordSchema = z9.object({
5998
6413
  id: z9.string(),
@@ -6001,7 +6416,8 @@ var environmentRecordSchema = z9.object({
6001
6416
  createdAt: timestampSchema,
6002
6417
  updatedAt: timestampSchema,
6003
6418
  createdByUserId: z9.string().nullable(),
6004
- updatedByUserId: z9.string().nullable()
6419
+ updatedByUserId: z9.string().nullable(),
6420
+ deletionLocked: z9.boolean()
6005
6421
  });
6006
6422
  var folderRecordSchema = z9.object({
6007
6423
  id: z9.string(),
@@ -6159,7 +6575,7 @@ function denySelfRoleChange(reply, targetUserId, actorUserId, existingRole, requ
6159
6575
  return true;
6160
6576
  }
6161
6577
  async function registerAdminRoutes(app, options) {
6162
- const { db, llm } = options;
6578
+ const { db, getLlm, reloadConfig } = options;
6163
6579
  const routes = app.withTypeProvider();
6164
6580
  routes.route({
6165
6581
  method: "GET",
@@ -6180,6 +6596,7 @@ async function registerAdminRoutes(app, options) {
6180
6596
  return;
6181
6597
  }
6182
6598
  const systemUserId = db.getSystemUserId();
6599
+ const llm = getLlm();
6183
6600
  const [users, collections, environments] = await Promise.all([
6184
6601
  db.listUsers(),
6185
6602
  db.listCollections(),
@@ -6232,6 +6649,7 @@ async function registerAdminRoutes(app, options) {
6232
6649
  return;
6233
6650
  }
6234
6651
  const input = buildAdminUserCreateInput(request.body);
6652
+ const llm = getLlm();
6235
6653
  const [collections, environments] = await Promise.all([
6236
6654
  db.listCollections(),
6237
6655
  db.listEnvironments()
@@ -6288,7 +6706,8 @@ async function registerAdminRoutes(app, options) {
6288
6706
  return reply.send({
6289
6707
  collections: collections.map((collection) => ({
6290
6708
  id: collection.id,
6291
- name: collection.name
6709
+ name: collection.name,
6710
+ deletionLocked: collection.deletionLocked
6292
6711
  }))
6293
6712
  });
6294
6713
  } catch (error) {
@@ -6321,7 +6740,8 @@ async function registerAdminRoutes(app, options) {
6321
6740
  return reply.send({
6322
6741
  environments: environments.map((environment) => ({
6323
6742
  id: environment.id,
6324
- name: environment.name
6743
+ name: environment.name,
6744
+ deletionLocked: environment.deletionLocked
6325
6745
  }))
6326
6746
  });
6327
6747
  } catch (error) {
@@ -6332,6 +6752,154 @@ async function registerAdminRoutes(app, options) {
6332
6752
  }
6333
6753
  }
6334
6754
  });
6755
+ routes.route({
6756
+ method: "DELETE",
6757
+ url: "/admin/collections/:id",
6758
+ schema: {
6759
+ params: idParamSchema,
6760
+ response: {
6761
+ 204: emptyResponseSchema,
6762
+ 403: errorResponseSchema,
6763
+ 404: errorResponseSchema
6764
+ }
6765
+ },
6766
+ /**
6767
+ * Deletes a collection regardless of deletion lock state.
6768
+ */
6769
+ handler: async (request, reply) => {
6770
+ try {
6771
+ const user = requireAuthenticatedUser(request);
6772
+ if (denyUnlessAllowed(reply, canUseManagementApi(user))) {
6773
+ return;
6774
+ }
6775
+ const collection = await db.findCollectionById(request.params.id);
6776
+ if (!collection) {
6777
+ void reply.code(404).send({ error: "Collection not found" });
6778
+ return;
6779
+ }
6780
+ await db.deleteCollection(request.params.id, user.id);
6781
+ return reply.code(204).send(null);
6782
+ } catch (error) {
6783
+ if (handleDbError(reply, error)) {
6784
+ return;
6785
+ }
6786
+ throw error;
6787
+ }
6788
+ }
6789
+ });
6790
+ routes.route({
6791
+ method: "PUT",
6792
+ url: "/admin/collections/:id",
6793
+ schema: {
6794
+ params: idParamSchema,
6795
+ body: updateAdminCollectionBodySchema,
6796
+ response: {
6797
+ 200: adminEntityConfigSchema,
6798
+ 403: errorResponseSchema,
6799
+ 404: errorResponseSchema
6800
+ }
6801
+ },
6802
+ /**
6803
+ * Updates admin configuration for a collection.
6804
+ */
6805
+ handler: async (request, reply) => {
6806
+ try {
6807
+ const user = requireAuthenticatedUser(request);
6808
+ if (denyUnlessAllowed(reply, canUseManagementApi(user))) {
6809
+ return;
6810
+ }
6811
+ const collection = await db.setCollectionDeletionLocked(
6812
+ request.params.id,
6813
+ request.body.deletionLocked,
6814
+ user.id
6815
+ );
6816
+ return reply.send({
6817
+ id: collection.id,
6818
+ name: collection.name,
6819
+ deletionLocked: collection.deletionLocked
6820
+ });
6821
+ } catch (error) {
6822
+ if (handleDbError(reply, error)) {
6823
+ return;
6824
+ }
6825
+ throw error;
6826
+ }
6827
+ }
6828
+ });
6829
+ routes.route({
6830
+ method: "DELETE",
6831
+ url: "/admin/environments/:id",
6832
+ schema: {
6833
+ params: idParamSchema,
6834
+ response: {
6835
+ 204: emptyResponseSchema,
6836
+ 403: errorResponseSchema,
6837
+ 404: errorResponseSchema
6838
+ }
6839
+ },
6840
+ /**
6841
+ * Deletes an environment regardless of deletion lock state.
6842
+ */
6843
+ handler: async (request, reply) => {
6844
+ try {
6845
+ const user = requireAuthenticatedUser(request);
6846
+ if (denyUnlessAllowed(reply, canUseManagementApi(user))) {
6847
+ return;
6848
+ }
6849
+ const environment = await db.findEnvironmentById(request.params.id);
6850
+ if (!environment) {
6851
+ void reply.code(404).send({ error: "Environment not found" });
6852
+ return;
6853
+ }
6854
+ await db.deleteEnvironment(request.params.id, user.id);
6855
+ return reply.code(204).send(null);
6856
+ } catch (error) {
6857
+ if (handleDbError(reply, error)) {
6858
+ return;
6859
+ }
6860
+ throw error;
6861
+ }
6862
+ }
6863
+ });
6864
+ routes.route({
6865
+ method: "PUT",
6866
+ url: "/admin/environments/:id",
6867
+ schema: {
6868
+ params: idParamSchema,
6869
+ body: updateAdminEnvironmentBodySchema,
6870
+ response: {
6871
+ 200: adminEntityConfigSchema,
6872
+ 403: errorResponseSchema,
6873
+ 404: errorResponseSchema
6874
+ }
6875
+ },
6876
+ /**
6877
+ * Updates admin configuration for an environment.
6878
+ */
6879
+ handler: async (request, reply) => {
6880
+ try {
6881
+ const user = requireAuthenticatedUser(request);
6882
+ if (denyUnlessAllowed(reply, canUseManagementApi(user))) {
6883
+ return;
6884
+ }
6885
+ const environment = await db.setEnvironmentDeletionLocked(
6886
+ request.params.id,
6887
+ request.body.deletionLocked,
6888
+ user.id
6889
+ );
6890
+ return reply.send({
6891
+ id: environment.id,
6892
+ name: environment.name,
6893
+ deletionLocked: environment.deletionLocked
6894
+ });
6895
+ } catch (error) {
6896
+ if (handleDbError(reply, error)) {
6897
+ return;
6898
+ }
6899
+ throw error;
6900
+ }
6901
+ }
6902
+ });
6335
6903
  routes.route({
6336
6904
  method: "GET",
6337
6905
  url: "/admin/llm/models",
@@ -6346,6 +6914,7 @@ async function registerAdminRoutes(app, options) {
6346
6914
  * Lists all hub-offered LLM models for operator user management.
6347
6915
  */
6348
6916
  handler: async (request, reply) => {
6917
+ const llm = getLlm();
6349
6918
  if (!llm) {
6350
6919
  return sendLlmUnavailable(reply);
6351
6920
  }
@@ -6397,6 +6966,7 @@ async function registerAdminRoutes(app, options) {
6397
6966
  }
6398
6967
  const input = buildAdminUserUpdateInput(existing, request.body);
6399
6968
  const role = request.body.role ?? existing.role;
6969
+ const llm = getLlm();
6400
6970
  const [collections, environments] = await Promise.all([
6401
6971
  db.listCollections(),
6402
6972
  db.listEnvironments()
@@ -6586,6 +7156,31 @@ async function registerAdminRoutes(app, options) {
6586
7156
  }
6587
7157
  }
6588
7158
  });
7159
+ routes.route({
7160
+ method: "POST",
7161
+ url: "/admin/config/reload",
7162
+ schema: {
7163
+ response: {
7164
+ 200: reloadConfigResponseSchema,
7165
+ 400: reloadConfigResponseSchema,
7166
+ 403: errorResponseSchema
7167
+ }
7168
+ },
7169
+ /**
7170
+ * Re-reads server.yaml and applies reloadable config sections on a best-effort basis.
7171
+ */
7172
+ handler: async (request, reply) => {
7173
+ const user = requireAuthenticatedUser(request);
7174
+ if (denyUnlessAllowed(reply, canUseManagementApi(user))) {
7175
+ return;
7176
+ }
7177
+ const result = await reloadConfig();
7178
+ if (result.fatalError) {
7179
+ return reply.code(400).send(result);
7180
+ }
7181
+ return reply.send(result);
7182
+ }
7183
+ });
6589
7184
  }
6590
7185
 
6591
7186
  // src/server/auth/sessionCapabilities.ts
@@ -6761,6 +7356,14 @@ async function registerCollectionRoutes(app, db) {
6761
7356
  )) {
6762
7357
  return;
6763
7358
  }
7359
+ const collection = await db.findCollectionById(request.params.id);
7360
+ if (!collection) {
7361
+ void reply.code(404).send({ error: "Collection not found" });
7362
+ return;
7363
+ }
7364
+ if (collection.deletionLocked) {
7365
+ throw new DeletionLockedError("collection");
7366
+ }
6764
7367
  await db.deleteCollection(request.params.id, user.id);
6765
7368
  return reply.code(204).send(null);
6766
7369
  } catch (error) {
@@ -6790,7 +7393,7 @@ async function registerEnvironmentRoutes(app, db) {
6790
7393
  handler: async (request, reply) => {
6791
7394
  try {
6792
7395
  const user = requireAuthenticatedUser(request);
6793
- if (denyUnlessAllowed(reply, canUseDataApi(user))) {
7396
+ if (denyUnlessAllowed(reply, canListEnvironments(user))) {
6794
7397
  return;
6795
7398
  }
6796
7399
  const environments = await db.listEnvironments();
@@ -6897,6 +7500,14 @@ async function registerEnvironmentRoutes(app, db) {
6897
7500
  )) {
6898
7501
  return;
6899
7502
  }
7503
+ const environment = await db.findEnvironmentById(request.params.id);
7504
+ if (!environment) {
7505
+ void reply.code(404).send({ error: "Environment not found" });
7506
+ return;
7507
+ }
7508
+ if (environment.deletionLocked) {
7509
+ throw new DeletionLockedError("environment");
7510
+ }
6900
7511
  await db.deleteEnvironment(request.params.id, user.id);
6901
7512
  return reply.code(204).send(null);
6902
7513
  } catch (error) {
@@ -7523,14 +8134,15 @@ async function registerLlmRoutes(app, options) {
7523
8134
  * Lists hub-offered models the authenticated user may use.
7524
8135
  */
7525
8136
  handler: async (request, reply) => {
7526
- if (!options.llm) {
8137
+ const llm = options.getLlm();
8138
+ if (!llm) {
7527
8139
  return sendLlmUnavailable2(reply);
7528
8140
  }
7529
8141
  const user = requireAuthenticatedUser(request);
7530
8142
  if (denyUnlessAllowed(reply, canUseLlm(user))) {
7531
8143
  return;
7532
8144
  }
7533
- const offered = listHubOfferedModels(options.llm).filter(
8145
+ const offered = listHubOfferedModels(llm).filter(
7534
8146
  (model) => isLlmModelAllowed(user, model.id)
7535
8147
  );
7536
8148
  return reply.send({
@@ -7556,7 +8168,8 @@ async function registerLlmRoutes(app, options) {
7556
8168
  * Returns the authenticated user's current monthly LLM usage summary.
7557
8169
  */
7558
8170
  handler: async (request, reply) => {
7559
- if (!options.llm) {
8171
+ const llm = options.getLlm();
8172
+ if (!llm) {
7560
8173
  return sendLlmUnavailable2(reply);
7561
8174
  }
7562
8175
  const user = requireAuthenticatedUser(request);
@@ -7588,7 +8201,8 @@ async function registerLlmRoutes(app, options) {
7588
8201
  * Runs one stateless LLM completion step using hub-configured provider keys.
7589
8202
  */
7590
8203
  handler: async (request, reply) => {
7591
- if (!options.llm) {
8204
+ const llm = options.getLlm();
8205
+ if (!llm) {
7592
8206
  return sendLlmUnavailable2(reply);
7593
8207
  }
7594
8208
  const user = requireAuthenticatedUser(request);
@@ -7596,7 +8210,7 @@ async function registerLlmRoutes(app, options) {
7596
8210
  return;
7597
8211
  }
7598
8212
  const { model, messages, tools, systemPrompt } = request.body;
7599
- if (!isHubModelOffered(options.llm, model)) {
8213
+ if (!isHubModelOffered(llm, model)) {
7600
8214
  return reply.code(403).send({ error: "Model is not offered by this Team Hub." });
7601
8215
  }
7602
8216
  if (!isLlmModelAllowed(user, model)) {
@@ -7610,7 +8224,7 @@ async function registerLlmRoutes(app, options) {
7610
8224
  if (isNewTurn && isOverMonthlyLimit(totalTokens, user.llmMonthlyTokenLimit)) {
7611
8225
  return sendMonthlyLimitExceeded(reply);
7612
8226
  }
7613
- const result = await runLlmCompletion(options.llm, {
8227
+ const result = await runLlmCompletion(llm, {
7614
8228
  model,
7615
8229
  messages,
7616
8230
  tools,
@@ -7648,6 +8262,43 @@ async function registerLlmRoutes(app, options) {
7648
8262
  });
7649
8263
  }
7650
8264
 
8265
+ // src/server/routes/schemas/plugins.ts
8266
+ import { z as z11 } from "zod/v4";
8267
+ var pluginSourcesResponseSchema = z11.object({
8268
+ catalogs: z11.array(z11.string()),
8269
+ trusted: z11.array(z11.string())
8270
+ });
8271
+
8272
+ // src/server/routes/plugins.ts
8273
+ async function registerPluginsRoutes(app, options) {
8274
+ const routes = app.withTypeProvider();
8275
+ routes.route({
8276
+ method: "GET",
8277
+ url: "/plugins/sources",
8278
+ schema: {
8279
+ response: {
8280
+ 200: pluginSourcesResponseSchema
8281
+ }
8282
+ },
8283
+ /**
8284
+ * Returns plugin catalog and trusted-publisher URLs configured on this Team Hub.
8285
+ */
8286
+ handler: async (_request, reply) => {
8287
+ const plugins = options.getPlugins();
8288
+ if (!plugins) {
8289
+ return reply.send({
8290
+ catalogs: [],
8291
+ trusted: []
8292
+ });
8293
+ }
8294
+ return reply.send({
8295
+ catalogs: plugins.catalogs,
8296
+ trusted: plugins.trusted
8297
+ });
8298
+ }
8299
+ });
8300
+ }
8301
+
7651
8302
  // src/server/auth/bearerAuthPlugin.ts
7652
8303
  function registerBearerAuthDecorator(app) {
7653
8304
  app.decorateRequest("apiToken", null);
@@ -7658,8 +8309,8 @@ function buildAuthThrottleKey(request, token) {
7658
8309
  return `${request.ip}:${tokenPart}`;
7659
8310
  }
7660
8311
  function createBearerAuthHook(db, throttleStore) {
7661
- const policy = throttleStore.getPolicy();
7662
8312
  return async function bearerAuth(request, reply) {
8313
+ const policy = throttleStore.getPolicy();
7663
8314
  const token = extractBearer(request.headers.authorization);
7664
8315
  const throttleKey = buildAuthThrottleKey(request, token);
7665
8316
  try {
@@ -7714,12 +8365,17 @@ async function registerProtectedRoutes(app, options) {
7714
8365
  registerBearerAuthDecorator(app);
7715
8366
  app.addHook("onRequest", createBearerAuthHook(options.db, options.throttleStore));
7716
8367
  await registerAuthRoutes(app);
7717
- await registerAdminRoutes(app, { db: options.db, llm: options.llm });
8368
+ await registerAdminRoutes(app, {
8369
+ db: options.db,
8370
+ getLlm: options.getLlm,
8371
+ reloadConfig: options.reloadConfig
8372
+ });
7718
8373
  await registerCollectionRoutes(app, options.db);
7719
8374
  await registerEnvironmentRoutes(app, options.db);
7720
8375
  await registerFolderRoutes(app, options.db);
7721
8376
  await registerRequestRoutes(app, options.db);
7722
- await registerLlmRoutes(app, { db: options.db, llm: options.llm });
8377
+ await registerLlmRoutes(app, { db: options.db, getLlm: options.getLlm });
8378
+ await registerPluginsRoutes(app, { getPlugins: options.getPlugins });
7723
8379
  }
7724
8380
  async function registerRoutes(app, options) {
7725
8381
  await app.register(async (publicApp) => {
@@ -7731,21 +8387,36 @@ async function registerRoutes(app, options) {
7731
8387
  }
7732
8388
 
7733
8389
  // src/server/createServer.ts
7734
- async function createServer(config, options) {
8390
+ async function createServer(ctxOrConfig, options = {}) {
8391
+ const isRuntimeContext = "getLlm" in ctxOrConfig && "configPath" in ctxOrConfig;
8392
+ const ctx = isRuntimeContext ? ctxOrConfig : null;
8393
+ const legacyConfig = isRuntimeContext ? null : ctxOrConfig;
8394
+ const db = options.db ?? ctx?.db;
8395
+ const throttleStore = options.throttleStore ?? ctx?.throttleStore;
8396
+ if (!db || !throttleStore) {
8397
+ throw new Error("createServer requires db and throttleStore.");
8398
+ }
8399
+ const logger = options.logger ?? ctx?.logger ?? createLogger(legacyConfig?.logging ?? DEFAULT_LOGGING_CONFIG);
7735
8400
  const app = Fastify({
7736
8401
  logger: options.verbose ?? false
7737
8402
  }).withTypeProvider();
7738
8403
  app.setValidatorCompiler(validatorCompiler);
7739
8404
  app.setSerializerCompiler(serializerCompiler);
8405
+ registerHttpLogging(app, logger);
7740
8406
  await registerRoutes(app, {
7741
8407
  version: options.version ?? readPackageVersion(),
7742
- db: options.db,
7743
- throttleStore: options.throttleStore,
7744
- llm: config.llm
8408
+ db,
8409
+ throttleStore,
8410
+ getLlm: ctx ? () => ctx.getLlm() : () => legacyConfig?.llm ?? null,
8411
+ getPlugins: ctx ? () => ctx.getPlugins() : () => legacyConfig?.plugins ?? null,
8412
+ reloadConfig: options.reloadConfig ?? (async () => ({ sections: [] }))
7745
8413
  });
7746
8414
  return app;
7747
8415
  }
7748
8416
 
8417
+ // src/server/runtimeContext.ts
8418
+ import { isDeepStrictEqual } from "util";
8419
+
7749
8420
  // src/server/auth/throttle/RedisThrottleStore.ts
7750
8421
  import Redis from "ioredis";
7751
8422
 
@@ -7885,6 +8556,151 @@ function createThrottleStore(config) {
7885
8556
  return RedisThrottleStore.fromConfig(config);
7886
8557
  }
7887
8558
 
8559
+ // src/server/runtimeContext.ts
8560
+ var runtimeContextStates = /* @__PURE__ */ new WeakMap();
8561
+ function createSwappableProxy(holder) {
8562
+ return new Proxy({}, {
8563
+ /**
8564
+ * Forwards property reads to the current underlying instance.
8565
+ */
8566
+ get(_target, prop) {
8567
+ const value = Reflect.get(holder.underlying, prop, holder.underlying);
8568
+ if (typeof value === "function") {
8569
+ return value.bind(holder.underlying);
8570
+ }
8571
+ return value;
8572
+ }
8573
+ });
8574
+ }
8575
+ function getState(ctx) {
8576
+ const state = runtimeContextStates.get(ctx);
8577
+ if (!state) {
8578
+ throw new Error("Invalid runtime context.");
8579
+ }
8580
+ return state;
8581
+ }
8582
+ function formatReloadError(error) {
8583
+ if (error instanceof Error) {
8584
+ return error.message;
8585
+ }
8586
+ return String(error);
8587
+ }
8588
+ function createRuntimeContext(config, configPath) {
8589
+ const state = {
8590
+ configPath,
8591
+ host: config.host,
8592
+ port: config.port,
8593
+ activeDbConfig: config.db,
8594
+ activeRedisConfig: config.redis,
8595
+ dbHolder: { underlying: createDatabase(config.db) },
8596
+ throttleHolder: { underlying: createThrottleStore(config.redis) },
8597
+ llm: config.llm,
8598
+ plugins: config.plugins
8599
+ };
8600
+ const ctx = {
8601
+ configPath: state.configPath,
8602
+ get host() {
8603
+ return state.host;
8604
+ },
8605
+ get port() {
8606
+ return state.port;
8607
+ },
8608
+ db: createSwappableProxy(state.dbHolder),
8609
+ throttleStore: createSwappableProxy(state.throttleHolder),
8610
+ getLlm: () => state.llm,
8611
+ getPlugins: () => state.plugins,
8612
+ logger: createLogger(config.logging)
8613
+ };
8614
+ runtimeContextStates.set(ctx, state);
8615
+ return ctx;
8616
+ }
8617
+ async function connectRuntimeContext(ctx) {
8618
+ const state = getState(ctx);
8619
+ await state.dbHolder.underlying.connect();
8620
+ await state.throttleHolder.underlying.connect();
8621
+ }
8622
+ async function disconnectAll(ctx) {
8623
+ const state = getState(ctx);
8624
+ await state.dbHolder.underlying.disconnect();
8625
+ await state.throttleHolder.underlying.disconnect();
8626
+ }
8627
+ async function reloadDbSection(state, nextDbConfig) {
8628
+ if (isDeepStrictEqual(state.activeDbConfig, nextDbConfig)) {
8629
+ return { section: "db", status: "unchanged" };
8630
+ }
8631
+ try {
8632
+ const nextDb = createDatabase(nextDbConfig);
8633
+ await nextDb.connect();
8634
+ const previousDb = state.dbHolder.underlying;
8635
+ state.dbHolder.underlying = nextDb;
8636
+ state.activeDbConfig = nextDbConfig;
8637
+ await previousDb.disconnect();
8638
+ return { section: "db", status: "reloaded" };
8639
+ } catch (error) {
8640
+ return { section: "db", status: "failed", error: formatReloadError(error) };
8641
+ }
8642
+ }
8643
+ async function reloadRedisSection(state, nextRedisConfig) {
8644
+ if (isDeepStrictEqual(state.activeRedisConfig, nextRedisConfig)) {
8645
+ return { section: "redis", status: "unchanged" };
8646
+ }
8647
+ try {
8648
+ const nextStore = createThrottleStore(nextRedisConfig);
8649
+ await nextStore.connect();
8650
+ const previousStore = state.throttleHolder.underlying;
8651
+ state.throttleHolder.underlying = nextStore;
8652
+ state.activeRedisConfig = nextRedisConfig;
8653
+ await previousStore.disconnect();
8654
+ return { section: "redis", status: "reloaded" };
8655
+ } catch (error) {
8656
+ return { section: "redis", status: "failed", error: formatReloadError(error) };
8657
+ }
8658
+ }
8659
+ function reloadServerSection(state, nextConfig) {
8660
+ if (state.host === nextConfig.host && state.port === nextConfig.port) {
8661
+ return { section: "server", status: "unchanged" };
8662
+ }
8663
+ return {
8664
+ section: "server",
8665
+ status: "restart-required",
8666
+ error: "Changes to server.host or server.port require a full process restart."
8667
+ };
8668
+ }
8669
+ async function reloadRuntimeConfig(ctx) {
8670
+ const state = getState(ctx);
8671
+ let nextConfig;
8672
+ try {
8673
+ nextConfig = loadServerConfig(state.configPath);
8674
+ } catch (error) {
8675
+ const message = error instanceof ConfigError ? error.message : formatReloadError(error);
8676
+ return { sections: [], fatalError: message };
8677
+ }
8678
+ const sections = [];
8679
+ sections.push(await reloadDbSection(state, nextConfig.db));
8680
+ sections.push(await reloadRedisSection(state, nextConfig.redis));
8681
+ state.llm = nextConfig.llm;
8682
+ sections.push({ section: "llm", status: "reloaded" });
8683
+ state.plugins = nextConfig.plugins;
8684
+ sections.push({ section: "plugins", status: "reloaded" });
8685
+ sections.push(reloadServerSection(state, nextConfig));
8686
+ return { sections };
8687
+ }
8688
+ function formatConfigReloadSummary(result) {
8689
+ return result.sections.map((section) => {
8690
+ if (section.error) {
8691
+ return `${section.section}: ${section.status} (${section.error})`;
8692
+ }
8693
+ return `${section.section}: ${section.status}`;
8694
+ }).join(", ");
8695
+ }
8696
+ function logConfigReloadResult(result) {
8697
+ if (result.fatalError) {
8698
+ console.error(`Team Hub config reload failed: ${result.fatalError}`);
8699
+ return;
8700
+ }
8701
+ console.log(`Team Hub config reloaded (${formatConfigReloadSummary(result)}).`);
8702
+ }
8703
+
7888
8704
  // src/server.ts
7889
8705
  function formatListenAddress(address, port) {
7890
8706
  if (!address) {
@@ -7896,12 +8712,11 @@ function formatListenAddress(address, port) {
7896
8712
  const host = address.includes(":") && !address.startsWith("[") ? `[${address}]` : address;
7897
8713
  return `http://${host}:${port}`;
7898
8714
  }
7899
- function registerGracefulShutdown(app, db, throttleStore) {
8715
+ function registerGracefulShutdown(app, ctx) {
7900
8716
  const shutdown = async (signal) => {
7901
8717
  app.log.info(`Received ${signal}, shutting down.`);
7902
8718
  await app.close();
7903
- await db.disconnect();
7904
- await throttleStore.disconnect();
8719
+ await disconnectAll(ctx);
7905
8720
  process.exit(0);
7906
8721
  };
7907
8722
  process.once("SIGINT", () => {
@@ -7911,33 +8726,42 @@ function registerGracefulShutdown(app, db, throttleStore) {
7911
8726
  void shutdown("SIGTERM");
7912
8727
  });
7913
8728
  }
7914
- async function runServer(config, options) {
7915
- const app = await createServer(config, {
8729
+ function registerConfigReloadHandler(reloadConfig) {
8730
+ process.on("SIGHUP", () => {
8731
+ void reloadConfig();
8732
+ });
8733
+ }
8734
+ async function runServer(ctx, options = {}) {
8735
+ const reloadConfig = async () => {
8736
+ const result = await reloadRuntimeConfig(ctx);
8737
+ logConfigReloadResult(result);
8738
+ return result;
8739
+ };
8740
+ const app = await createServer(ctx, {
7916
8741
  verbose: options.verbose,
7917
- db: options.db,
7918
- throttleStore: options.throttleStore
8742
+ reloadConfig
7919
8743
  });
7920
- await options.db.connect();
7921
- await options.throttleStore.connect();
8744
+ await connectRuntimeContext(ctx);
7922
8745
  await app.listen({
7923
- host: config.host,
7924
- port: config.port
8746
+ host: ctx.host,
8747
+ port: ctx.port
7925
8748
  });
7926
8749
  const address = app.server.address();
7927
- const port = typeof address === "object" && address ? address.port : config.port;
7928
- const host = typeof address === "object" && address ? address.address : config.host;
8750
+ const port = typeof address === "object" && address ? address.port : ctx.port;
8751
+ const host = typeof address === "object" && address ? address.address : ctx.host;
7929
8752
  if (options.verbose) {
7930
- console.log("Starting server with config:", config);
8753
+ console.log("Starting server with config path:", ctx.configPath);
7931
8754
  }
7932
8755
  console.log(`Team Hub listening on ${formatListenAddress(host, port)}`);
7933
- registerGracefulShutdown(app, options.db, options.throttleStore);
8756
+ registerGracefulShutdown(app, ctx);
8757
+ registerConfigReloadHandler(reloadConfig);
7934
8758
  return app;
7935
8759
  }
7936
8760
  async function startCommand(options) {
8761
+ const configPath = resolveConfigPath(options.config);
7937
8762
  const config = loadServerConfig(options.config);
7938
- const db = createDatabase(config.db);
7939
- const throttleStore = createThrottleStore(config.redis);
7940
- await runServer(config, { verbose: options.verbose, db, throttleStore });
8763
+ const ctx = createRuntimeContext(config, configPath);
8764
+ await runServer(ctx, { verbose: options.verbose });
7941
8765
  }
7942
8766
  function registerStartCommand(program, handler = startCommand) {
7943
8767
  program.command("start").description("Start the Team Hub server").action(