remote-codex 0.11.24 → 0.11.25

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.
@@ -18,16 +18,17 @@ var RelayRequestBroker = class {
18
18
  }
19
19
  timeoutMs;
20
20
  pendingRequests = /* @__PURE__ */ new Map();
21
- forward(socket, message) {
21
+ forward(socket, message, options) {
22
22
  return new Promise((resolve, reject) => {
23
23
  if (message.type !== "relay.request") {
24
24
  reject(new Error("Only relay.request messages can be forwarded."));
25
25
  return;
26
26
  }
27
+ const timeoutMs = options?.timeoutMs ?? this.timeoutMs;
27
28
  const timeout = setTimeout(() => {
28
29
  this.pendingRequests.delete(message.requestId);
29
30
  reject(new Error("Supervisor relay request timed out."));
30
- }, this.timeoutMs);
31
+ }, timeoutMs);
31
32
  this.pendingRequests.set(message.requestId, {
32
33
  resolve,
33
34
  reject,
@@ -115,6 +116,53 @@ var RelayStore = class _RelayStore {
115
116
  this.insertUser(user);
116
117
  return this.createLoginResult(user);
117
118
  }
119
+ requestRegistrationApproval(input) {
120
+ if (!this.registrationEnabled()) {
121
+ throw new RelayStoreError(403, "forbidden", "Registration is currently disabled.");
122
+ }
123
+ const email = input.email.trim().toLowerCase();
124
+ const username = normalizeUsername(input.username);
125
+ if (!email.includes("@")) {
126
+ throw new RelayStoreError(400, "bad_request", "A valid email address is required.");
127
+ }
128
+ if (username.length < 3) {
129
+ throw new RelayStoreError(400, "bad_request", "Username must be at least 3 characters.");
130
+ }
131
+ if (input.password.length < 8) {
132
+ throw new RelayStoreError(400, "bad_request", "Password must be at least 8 characters.");
133
+ }
134
+ if (this.getUserByIdentifier(email) || this.getUserByUsername(username)) {
135
+ throw new RelayStoreError(409, "conflict", "A user with that email or username already exists.");
136
+ }
137
+ const existing = this.rowToPendingRegistration(
138
+ this.sqlite.prepare(
139
+ `
140
+ SELECT * FROM relay_pending_registrations
141
+ WHERE status = 'pending'
142
+ AND (email = ? OR username = ?)
143
+ ORDER BY created_at DESC
144
+ LIMIT 1
145
+ `
146
+ ).get(email, username)
147
+ );
148
+ if (existing) {
149
+ return this.publicPendingRegistration(existing);
150
+ }
151
+ const passwordSalt = crypto.randomBytes(16).toString("base64url");
152
+ const record = {
153
+ id: crypto.randomUUID(),
154
+ email,
155
+ username,
156
+ passwordSalt,
157
+ passwordHash: hashSecret(input.password, passwordSalt),
158
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
159
+ status: "pending",
160
+ reviewedAt: null,
161
+ reviewedByUserId: null
162
+ };
163
+ this.insertPendingRegistration(record);
164
+ return this.publicPendingRegistration(record);
165
+ }
118
166
  login(input) {
119
167
  const normalizedIdentifier = input.identifier.trim().toLowerCase();
120
168
  const user = this.getUserByIdentifier(normalizedIdentifier);
@@ -220,7 +268,9 @@ var RelayStore = class _RelayStore {
220
268
  deviceId: input.deviceId,
221
269
  deviceName: device.name,
222
270
  threadId: input.threadId,
271
+ threadTitle: null,
223
272
  workspaceId: input.workspaceId?.trim() || null,
273
+ workspaceLabel: null,
224
274
  label: input.label?.trim() || null,
225
275
  threadAccess: normalizeThreadAccess(input.threadAccess),
226
276
  workspaceAccess: normalizeWorkspaceAccess(input.workspaceAccess),
@@ -353,12 +403,32 @@ var RelayStore = class _RelayStore {
353
403
  sharedByMe: sharedByMe.map((share) => this.publicShare(share))
354
404
  };
355
405
  }
356
- adminSummary(connectedDevices) {
406
+ adminSummary(connectedDevices, options = {}) {
407
+ const conversationWindowDays = normalizeConversationWindowDays(options.conversationWindowDays);
408
+ const users = this.getUsers();
409
+ const devices = this.getDevices();
410
+ const deviceCounts = /* @__PURE__ */ new Map();
411
+ for (const device of devices) {
412
+ deviceCounts.set(device.ownerUserId, (deviceCounts.get(device.ownerUserId) ?? 0) + 1);
413
+ }
414
+ const conversationCounts = this.conversationCountsByUser(conversationWindowDays);
357
415
  return {
358
- users: this.getUsers().map((user) => this.publicUser(user)),
359
- devices: this.getDevices().map(
360
- (device) => this.publicDevice(device, connectedDevices.get(device.id) ?? null)
416
+ users: users.map(
417
+ (user) => this.publicAdminUser(user, deviceCounts.get(user.id) ?? 0, conversationCounts.get(user.id) ?? 0)
361
418
  ),
419
+ devices: devices.map((device) => {
420
+ const owner = users.find((user) => user.id === device.ownerUserId);
421
+ return this.publicAdminDevice(
422
+ device,
423
+ connectedDevices.get(device.id) ?? null,
424
+ owner,
425
+ options.metadata
426
+ );
427
+ }),
428
+ shares: this.getShares({ includeRevoked: true }).map((share) => this.publicShare(share)),
429
+ pendingRegistrations: this.pendingRegistrations(),
430
+ settings: this.registrationSettings(),
431
+ conversationWindowDays,
362
432
  registrationEnabled: this.registrationEnabled()
363
433
  };
364
434
  }
@@ -366,6 +436,95 @@ var RelayStore = class _RelayStore {
366
436
  this.setSetting("registrationEnabled", enabled ? "true" : "false");
367
437
  return enabled;
368
438
  }
439
+ registrationSettings() {
440
+ return {
441
+ enabled: this.registrationEnabled(),
442
+ registrationPassword: this.getSetting("registrationPassword"),
443
+ approvalRequired: this.getSetting("registrationApprovalRequired") === "true"
444
+ };
445
+ }
446
+ updateRegistrationSettings(input) {
447
+ if (input.enabled !== void 0) {
448
+ this.setRegistrationEnabled(input.enabled);
449
+ }
450
+ if (input.registrationPassword !== void 0) {
451
+ const password = input.registrationPassword?.trim() || null;
452
+ if (password !== null && password.length < 8) {
453
+ throw new RelayStoreError(400, "bad_request", "Registration password must be at least 8 characters.");
454
+ }
455
+ if (password === null) {
456
+ this.deleteSetting("registrationPassword");
457
+ } else {
458
+ this.setSetting("registrationPassword", password);
459
+ }
460
+ }
461
+ if (input.approvalRequired !== void 0) {
462
+ this.setSetting("registrationApprovalRequired", input.approvalRequired ? "true" : "false");
463
+ }
464
+ return this.registrationSettings();
465
+ }
466
+ ensureRegistrationPassword(password) {
467
+ if (!password || this.getSetting("registrationPassword") !== null) {
468
+ return;
469
+ }
470
+ this.setSetting("registrationPassword", password);
471
+ }
472
+ approvePendingRegistration(adminUserId, requestId) {
473
+ const record = this.requirePendingRegistration(requestId);
474
+ const user = {
475
+ id: crypto.randomUUID(),
476
+ email: record.email,
477
+ username: record.username,
478
+ role: "user",
479
+ enabled: true,
480
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
481
+ lastSeenAt: null,
482
+ passwordSalt: record.passwordSalt,
483
+ passwordHash: record.passwordHash
484
+ };
485
+ const approve = this.sqlite.transaction(() => {
486
+ this.insertUser(user);
487
+ this.sqlite.prepare(
488
+ `
489
+ UPDATE relay_pending_registrations
490
+ SET status = 'approved', reviewed_at = ?, reviewed_by_user_id = ?
491
+ WHERE id = ?
492
+ `
493
+ ).run((/* @__PURE__ */ new Date()).toISOString(), adminUserId, requestId);
494
+ });
495
+ approve();
496
+ return this.publicUser(user);
497
+ }
498
+ rejectPendingRegistration(adminUserId, requestId) {
499
+ this.requirePendingRegistration(requestId);
500
+ this.sqlite.prepare(
501
+ `
502
+ UPDATE relay_pending_registrations
503
+ SET status = 'rejected', reviewed_at = ?, reviewed_by_user_id = ?
504
+ WHERE id = ?
505
+ `
506
+ ).run((/* @__PURE__ */ new Date()).toISOString(), adminUserId, requestId);
507
+ return { id: requestId };
508
+ }
509
+ recordUserSeen(userId, at = (/* @__PURE__ */ new Date()).toISOString()) {
510
+ this.sqlite.prepare("UPDATE relay_users SET last_seen_at = ? WHERE id = ?").run(at, userId);
511
+ }
512
+ recordConversationEvent(input) {
513
+ this.sqlite.prepare(
514
+ `
515
+ INSERT INTO relay_conversation_events (
516
+ id, user_id, device_id, thread_id, workspace_id, occurred_at
517
+ ) VALUES (?, ?, ?, ?, ?, ?)
518
+ `
519
+ ).run(
520
+ crypto.randomUUID(),
521
+ input.userId,
522
+ input.deviceId,
523
+ input.threadId,
524
+ input.workspaceId,
525
+ (/* @__PURE__ */ new Date()).toISOString()
526
+ );
527
+ }
369
528
  setUserEnabled(userId, enabled) {
370
529
  const user = this.requireUser(userId);
371
530
  if (user.role === "admin" && !enabled) {
@@ -374,6 +533,30 @@ var RelayStore = class _RelayStore {
374
533
  this.sqlite.prepare("UPDATE relay_users SET enabled = ? WHERE id = ?").run(enabled ? 1 : 0, userId);
375
534
  return this.publicUser({ ...user, enabled });
376
535
  }
536
+ deleteUser(userId) {
537
+ const user = this.requireUser(userId);
538
+ if (user.role === "admin") {
539
+ throw new RelayStoreError(400, "bad_request", "The admin user cannot be deleted.");
540
+ }
541
+ this.sqlite.prepare("DELETE FROM relay_users WHERE id = ?").run(userId);
542
+ }
543
+ adminResetUserPassword(userId, password) {
544
+ const user = this.requireUser(userId);
545
+ if (user.role === "admin") {
546
+ throw new RelayStoreError(400, "bad_request", "The admin user password cannot be reset here.");
547
+ }
548
+ if (password.length < 8) {
549
+ throw new RelayStoreError(400, "bad_request", "Password must be at least 8 characters.");
550
+ }
551
+ const passwordSalt = crypto.randomBytes(16).toString("base64url");
552
+ const passwordHash = hashSecret(password, passwordSalt);
553
+ this.sqlite.prepare("UPDATE relay_users SET password_salt = ?, password_hash = ? WHERE id = ?").run(passwordSalt, passwordHash, user.id);
554
+ return this.publicUser({
555
+ ...user,
556
+ passwordSalt,
557
+ passwordHash
558
+ });
559
+ }
377
560
  updateAccount(userId, input) {
378
561
  const user = this.requireUser(userId);
379
562
  const username = input.username !== void 0 ? normalizeUsername(input.username) : user.username;
@@ -424,6 +607,24 @@ var RelayStore = class _RelayStore {
424
607
  createdAt: device.createdAt
425
608
  };
426
609
  }
610
+ publicAdminUser(user, deviceCount, conversationCount) {
611
+ return {
612
+ ...this.publicUser(user),
613
+ lastSeenAt: user.lastSeenAt,
614
+ deviceCount,
615
+ conversationCount
616
+ };
617
+ }
618
+ publicAdminDevice(device, status, owner, metadata) {
619
+ return {
620
+ ...this.publicDevice(device, status),
621
+ ownerUsername: owner?.username ?? "unknown",
622
+ ownerEmail: owner?.email ?? "unknown",
623
+ ipAddress: status?.ipAddress ?? null,
624
+ workspaces: metadata?.workspacesByDeviceId?.get(device.id) ?? [],
625
+ threads: metadata?.threadsByDeviceId?.get(device.id) ?? []
626
+ };
627
+ }
427
628
  recordShareAccess(share, user) {
428
629
  if (share.revokedAt || share.expiresAt && share.expiresAt <= (/* @__PURE__ */ new Date()).toISOString()) {
429
630
  return;
@@ -466,6 +667,7 @@ var RelayStore = class _RelayStore {
466
667
  username TEXT NOT NULL UNIQUE,
467
668
  role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
468
669
  enabled INTEGER NOT NULL DEFAULT 1,
670
+ last_seen_at TEXT,
469
671
  created_at TEXT NOT NULL,
470
672
  password_salt TEXT NOT NULL,
471
673
  password_hash TEXT NOT NULL
@@ -514,7 +716,33 @@ var RelayStore = class _RelayStore {
514
716
  );
515
717
 
516
718
  CREATE INDEX IF NOT EXISTS relay_share_access_events_share_idx ON relay_share_access_events(share_id, accessed_at DESC);
719
+
720
+ CREATE TABLE IF NOT EXISTS relay_conversation_events (
721
+ id TEXT PRIMARY KEY,
722
+ user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
723
+ device_id TEXT NOT NULL REFERENCES relay_devices(id) ON DELETE CASCADE,
724
+ thread_id TEXT,
725
+ workspace_id TEXT,
726
+ occurred_at TEXT NOT NULL
727
+ );
728
+
729
+ CREATE INDEX IF NOT EXISTS relay_conversation_events_user_time_idx ON relay_conversation_events(user_id, occurred_at DESC);
730
+
731
+ CREATE TABLE IF NOT EXISTS relay_pending_registrations (
732
+ id TEXT PRIMARY KEY,
733
+ email TEXT NOT NULL,
734
+ username TEXT NOT NULL,
735
+ password_salt TEXT NOT NULL,
736
+ password_hash TEXT NOT NULL,
737
+ created_at TEXT NOT NULL,
738
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
739
+ reviewed_at TEXT,
740
+ reviewed_by_user_id TEXT
741
+ );
742
+
743
+ CREATE INDEX IF NOT EXISTS relay_pending_registrations_status_idx ON relay_pending_registrations(status, created_at DESC);
517
744
  `);
745
+ this.ensureColumn("relay_users", "last_seen_at", "TEXT");
518
746
  this.ensureColumn("relay_devices", "token", "TEXT");
519
747
  this.ensureColumn("relay_shares", "workspace_id", "TEXT");
520
748
  this.ensureColumn("relay_shares", "thread_access", "TEXT NOT NULL DEFAULT 'control'");
@@ -574,6 +802,9 @@ var RelayStore = class _RelayStore {
574
802
  setSetting(key, value) {
575
803
  this.sqlite.prepare("INSERT INTO relay_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
576
804
  }
805
+ deleteSetting(key) {
806
+ this.sqlite.prepare("DELETE FROM relay_settings WHERE key = ?").run(key);
807
+ }
577
808
  createStoredUser(input) {
578
809
  const email = input.email.trim().toLowerCase();
579
810
  const username = normalizeUsername(input.username);
@@ -597,6 +828,7 @@ var RelayStore = class _RelayStore {
597
828
  role: input.role,
598
829
  enabled: true,
599
830
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
831
+ lastSeenAt: null,
600
832
  passwordSalt,
601
833
  passwordHash: hashSecret(input.password, passwordSalt)
602
834
  };
@@ -662,8 +894,8 @@ var RelayStore = class _RelayStore {
662
894
  this.sqlite.prepare(
663
895
  `
664
896
  INSERT INTO relay_users (
665
- id, email, username, role, enabled, created_at, password_salt, password_hash
666
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
897
+ id, email, username, role, enabled, last_seen_at, created_at, password_salt, password_hash
898
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
667
899
  `
668
900
  ).run(
669
901
  user.id,
@@ -671,6 +903,7 @@ var RelayStore = class _RelayStore {
671
903
  user.username,
672
904
  user.role,
673
905
  user.enabled ? 1 : 0,
906
+ user.lastSeenAt,
674
907
  user.createdAt,
675
908
  user.passwordSalt,
676
909
  user.passwordHash
@@ -790,6 +1023,72 @@ var RelayStore = class _RelayStore {
790
1023
  getSharesByTarget(targetUserId) {
791
1024
  return this.sqlite.prepare("SELECT * FROM relay_shares WHERE target_user_id = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at ASC").all(targetUserId, (/* @__PURE__ */ new Date()).toISOString()).map((row) => this.rowToShare(row)).filter((share) => Boolean(share));
792
1025
  }
1026
+ getShares(options = {}) {
1027
+ const sql = options.includeRevoked ? "SELECT * FROM relay_shares ORDER BY created_at DESC" : "SELECT * FROM relay_shares WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at DESC";
1028
+ const rows = options.includeRevoked ? this.sqlite.prepare(sql).all() : this.sqlite.prepare(sql).all((/* @__PURE__ */ new Date()).toISOString());
1029
+ return rows.map((row) => this.rowToShare(row)).filter((share) => Boolean(share));
1030
+ }
1031
+ conversationCountsByUser(days) {
1032
+ const since = new Date(Date.now() - days * 24 * 60 * 60 * 1e3).toISOString();
1033
+ const rows = this.sqlite.prepare(
1034
+ `
1035
+ SELECT user_id, COUNT(*) AS count
1036
+ FROM relay_conversation_events
1037
+ WHERE occurred_at >= ?
1038
+ GROUP BY user_id
1039
+ `
1040
+ ).all(since);
1041
+ return new Map(rows.map((row) => [row.user_id, row.count]));
1042
+ }
1043
+ pendingRegistrations() {
1044
+ return this.sqlite.prepare(
1045
+ `
1046
+ SELECT * FROM relay_pending_registrations
1047
+ WHERE status = 'pending'
1048
+ ORDER BY created_at ASC
1049
+ `
1050
+ ).all().map((row) => this.rowToPendingRegistration(row)).filter((record) => Boolean(record)).map((record) => this.publicPendingRegistration(record));
1051
+ }
1052
+ requirePendingRegistration(id) {
1053
+ const record = this.rowToPendingRegistration(
1054
+ this.sqlite.prepare("SELECT * FROM relay_pending_registrations WHERE id = ? AND status = 'pending'").get(id)
1055
+ );
1056
+ if (!record) {
1057
+ throw new RelayStoreError(404, "not_found", "Pending registration was not found.");
1058
+ }
1059
+ if (this.getUserByIdentifier(record.email) || this.getUserByUsername(record.username)) {
1060
+ throw new RelayStoreError(409, "conflict", "A user with that email or username already exists.");
1061
+ }
1062
+ return record;
1063
+ }
1064
+ publicPendingRegistration(record) {
1065
+ return {
1066
+ id: record.id,
1067
+ email: record.email,
1068
+ username: record.username,
1069
+ createdAt: record.createdAt
1070
+ };
1071
+ }
1072
+ insertPendingRegistration(record) {
1073
+ this.sqlite.prepare(
1074
+ `
1075
+ INSERT INTO relay_pending_registrations (
1076
+ id, email, username, password_salt, password_hash,
1077
+ created_at, status, reviewed_at, reviewed_by_user_id
1078
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1079
+ `
1080
+ ).run(
1081
+ record.id,
1082
+ record.email,
1083
+ record.username,
1084
+ record.passwordSalt,
1085
+ record.passwordHash,
1086
+ record.createdAt,
1087
+ record.status,
1088
+ record.reviewedAt,
1089
+ record.reviewedByUserId
1090
+ );
1091
+ }
793
1092
  rowToUser(row) {
794
1093
  if (!row) return null;
795
1094
  return {
@@ -799,6 +1098,7 @@ var RelayStore = class _RelayStore {
799
1098
  role: row.role,
800
1099
  enabled: Boolean(row.enabled),
801
1100
  createdAt: row.created_at,
1101
+ lastSeenAt: row.last_seen_at ?? null,
802
1102
  passwordSalt: row.password_salt,
803
1103
  passwordHash: row.password_hash
804
1104
  };
@@ -826,7 +1126,9 @@ var RelayStore = class _RelayStore {
826
1126
  deviceId: row.device_id,
827
1127
  deviceName: row.device_name ?? "Remote Codex device",
828
1128
  threadId: row.thread_id,
1129
+ threadTitle: null,
829
1130
  workspaceId: row.workspace_id ?? null,
1131
+ workspaceLabel: null,
830
1132
  label: row.label,
831
1133
  threadAccess: normalizeThreadAccess(row.thread_access),
832
1134
  workspaceAccess: normalizeWorkspaceAccess(row.workspace_access),
@@ -838,6 +1140,20 @@ var RelayStore = class _RelayStore {
838
1140
  accessEvents: []
839
1141
  };
840
1142
  }
1143
+ rowToPendingRegistration(row) {
1144
+ if (!row) return null;
1145
+ return {
1146
+ id: row.id,
1147
+ email: row.email,
1148
+ username: row.username,
1149
+ passwordSalt: row.password_salt,
1150
+ passwordHash: row.password_hash,
1151
+ createdAt: row.created_at,
1152
+ status: row.status,
1153
+ reviewedAt: row.reviewed_at ?? null,
1154
+ reviewedByUserId: row.reviewed_by_user_id ?? null
1155
+ };
1156
+ }
841
1157
  };
842
1158
  var RelayStoreError = class extends Error {
843
1159
  constructor(statusCode, code, message) {
@@ -867,6 +1183,12 @@ function normalizeExpiresAt(value) {
867
1183
  const timestamp = Date.parse(value);
868
1184
  return Number.isNaN(timestamp) ? null : new Date(timestamp).toISOString();
869
1185
  }
1186
+ function normalizeConversationWindowDays(value) {
1187
+ if (!Number.isFinite(value ?? NaN)) {
1188
+ return 7;
1189
+ }
1190
+ return Math.min(365, Math.max(1, Math.floor(value)));
1191
+ }
870
1192
  function hashSecret(secret, salt) {
871
1193
  return crypto.scryptSync(secret, salt, 32).toString("base64url");
872
1194
  }
@@ -890,6 +1212,7 @@ function safeEqual(left, right) {
890
1212
 
891
1213
  // src/app.ts
892
1214
  var RELAY_REQUEST_TIMEOUT_MS = 3e4;
1215
+ var RELAY_PORTAL_METADATA_TIMEOUT_MS = 900;
893
1216
  var WEBSOCKET_OPEN = 1;
894
1217
  var RELAY_COOKIE_NAME = "remote_codex_relay_session";
895
1218
  var threadAccessSchema = z.enum(["read", "control"]);
@@ -931,6 +1254,17 @@ var updateShareSchema = z.object({
931
1254
  var setEnabledSchema = z.object({
932
1255
  enabled: z.boolean()
933
1256
  });
1257
+ var adminResetPasswordSchema = z.object({
1258
+ password: z.string().min(8)
1259
+ });
1260
+ var adminQuerySchema = z.object({
1261
+ days: z.coerce.number().int().positive().max(365).optional()
1262
+ });
1263
+ var updateRegistrationSettingsSchema = z.object({
1264
+ enabled: z.boolean().optional(),
1265
+ registrationPassword: z.string().nullable().optional(),
1266
+ approvalRequired: z.boolean().optional()
1267
+ });
934
1268
  var updateAccountSchema = z.object({
935
1269
  username: z.string().trim().min(3).optional()
936
1270
  });
@@ -1027,6 +1361,7 @@ function buildRelayServer(config2, options = {}) {
1027
1361
  if (config2.registrationEnabledConfigured) {
1028
1362
  store.setRegistrationEnabled(config2.registrationEnabled);
1029
1363
  }
1364
+ store.ensureRegistrationPassword(config2.registrationPassword);
1030
1365
  store.seedAdmin({
1031
1366
  username: config2.adminUsername,
1032
1367
  email: config2.adminEmail,
@@ -1064,21 +1399,35 @@ function buildRelayServer(config2, options = {}) {
1064
1399
  });
1065
1400
  app2.post("/relay/auth/register", async (request, reply) => {
1066
1401
  const body = registerSchema.parse(request.body ?? {});
1067
- if (config2.registrationPassword && body.registrationPassword !== config2.registrationPassword) {
1402
+ const settings = store.registrationSettings();
1403
+ if (settings.registrationPassword && body.registrationPassword !== settings.registrationPassword) {
1068
1404
  reply.status(403).send({
1069
1405
  code: "forbidden",
1070
1406
  message: "Invalid registration password."
1071
1407
  });
1072
1408
  return;
1073
1409
  }
1074
- const { registrationPassword: _registrationPassword, ...registerInput } = body;
1410
+ const registerInput = {
1411
+ email: body.email,
1412
+ username: body.username,
1413
+ password: body.password
1414
+ };
1415
+ if (settings.approvalRequired) {
1416
+ reply.status(202);
1417
+ return {
1418
+ pendingApproval: true,
1419
+ request: store.requestRegistrationApproval(registerInput)
1420
+ };
1421
+ }
1075
1422
  const result = store.register(registerInput);
1423
+ store.recordUserSeen(result.session.user.id);
1076
1424
  attachRelayCookie(reply, result.token);
1077
1425
  return result;
1078
1426
  });
1079
1427
  app2.post("/relay/auth/login", async (request, reply) => {
1080
1428
  const body = loginSchema.parse(request.body ?? {});
1081
1429
  const result = store.login(body);
1430
+ store.recordUserSeen(result.session.user.id);
1082
1431
  attachRelayCookie(reply, result.token);
1083
1432
  return result;
1084
1433
  });
@@ -1109,7 +1458,7 @@ function buildRelayServer(config2, options = {}) {
1109
1458
  if (!user) {
1110
1459
  return;
1111
1460
  }
1112
- return store.portalSummary(user.id, connectionStatus(state));
1461
+ return enrichPortalSummary(store.portalSummary(user.id, connectionStatus(state)), state);
1113
1462
  });
1114
1463
  app2.get("/relay/access", async (request, reply) => {
1115
1464
  const user = requireRelayUser(request, reply, store);
@@ -1186,15 +1535,27 @@ function buildRelayServer(config2, options = {}) {
1186
1535
  if (!user) {
1187
1536
  return;
1188
1537
  }
1189
- return store.adminSummary(connectionStatus(state));
1538
+ const query = adminQuerySchema.parse(request.query ?? {});
1539
+ const baseSummary = store.adminSummary(connectionStatus(state), {
1540
+ ...query.days !== void 0 ? { conversationWindowDays: query.days } : {}
1541
+ });
1542
+ return enrichAdminSummary(baseSummary, state, store, query.days);
1190
1543
  });
1191
1544
  app2.patch("/relay/admin/settings/registration", async (request, reply) => {
1192
1545
  const user = requireRelayUser(request, reply, store, { admin: true });
1193
1546
  if (!user) {
1194
1547
  return;
1195
1548
  }
1196
- const body = setEnabledSchema.parse(request.body ?? {});
1197
- return { registrationEnabled: store.setRegistrationEnabled(body.enabled) };
1549
+ const body = updateRegistrationSettingsSchema.parse(request.body ?? {});
1550
+ const settings = store.updateRegistrationSettings({
1551
+ ...body.enabled !== void 0 ? { enabled: body.enabled } : {},
1552
+ ...body.registrationPassword !== void 0 ? { registrationPassword: body.registrationPassword } : {},
1553
+ ...body.approvalRequired !== void 0 ? { approvalRequired: body.approvalRequired } : {}
1554
+ });
1555
+ return {
1556
+ registrationEnabled: settings.enabled,
1557
+ settings
1558
+ };
1198
1559
  });
1199
1560
  app2.patch("/relay/admin/users/:userId", async (request, reply) => {
1200
1561
  const user = requireRelayUser(request, reply, store, { admin: true });
@@ -1205,6 +1566,40 @@ function buildRelayServer(config2, options = {}) {
1205
1566
  const body = setEnabledSchema.parse(request.body ?? {});
1206
1567
  return store.setUserEnabled(userId, body.enabled);
1207
1568
  });
1569
+ app2.delete("/relay/admin/users/:userId", async (request, reply) => {
1570
+ const user = requireRelayUser(request, reply, store, { admin: true });
1571
+ if (!user) {
1572
+ return;
1573
+ }
1574
+ const { userId } = z.object({ userId: z.string().uuid() }).parse(request.params);
1575
+ store.deleteUser(userId);
1576
+ return { id: userId };
1577
+ });
1578
+ app2.post("/relay/admin/users/:userId/reset-password", async (request, reply) => {
1579
+ const user = requireRelayUser(request, reply, store, { admin: true });
1580
+ if (!user) {
1581
+ return;
1582
+ }
1583
+ const { userId } = z.object({ userId: z.string().uuid() }).parse(request.params);
1584
+ const body = adminResetPasswordSchema.parse(request.body ?? {});
1585
+ return store.adminResetUserPassword(userId, body.password);
1586
+ });
1587
+ app2.post("/relay/admin/registrations/:requestId/approve", async (request, reply) => {
1588
+ const user = requireRelayUser(request, reply, store, { admin: true });
1589
+ if (!user) {
1590
+ return;
1591
+ }
1592
+ const { requestId } = z.object({ requestId: z.string().uuid() }).parse(request.params);
1593
+ return store.approvePendingRegistration(user.id, requestId);
1594
+ });
1595
+ app2.post("/relay/admin/registrations/:requestId/reject", async (request, reply) => {
1596
+ const user = requireRelayUser(request, reply, store, { admin: true });
1597
+ if (!user) {
1598
+ return;
1599
+ }
1600
+ const { requestId } = z.object({ requestId: z.string().uuid() }).parse(request.params);
1601
+ return store.rejectPendingRegistration(user.id, requestId);
1602
+ });
1208
1603
  app2.all("/relay/devices/:deviceId/api/*", async (request, reply) => {
1209
1604
  const user = requireRelayUser(request, reply, store);
1210
1605
  if (!user) {
@@ -1297,7 +1692,8 @@ function buildRelayServer(config2, options = {}) {
1297
1692
  clientSockets: /* @__PURE__ */ new Map(),
1298
1693
  connected: true,
1299
1694
  connectedAt,
1300
- lastHeartbeatAt: connectedAt
1695
+ lastHeartbeatAt: connectedAt,
1696
+ ipAddress: relayClientIp(request)
1301
1697
  };
1302
1698
  state.supervisors.set(deviceId, connection);
1303
1699
  socket.send(
@@ -1479,6 +1875,19 @@ async function forwardRelayHttp(input) {
1479
1875
  if (access.kind === "shared") {
1480
1876
  input.store.recordShareAccess(access.share, input.user);
1481
1877
  }
1878
+ const conversationEvent = conversationEventFromRequest(
1879
+ input.request.method,
1880
+ input.targetPath,
1881
+ input.request.body
1882
+ );
1883
+ if (conversationEvent) {
1884
+ input.store.recordConversationEvent({
1885
+ userId: input.user.id,
1886
+ deviceId: input.deviceId,
1887
+ threadId: conversationEvent.threadId,
1888
+ workspaceId: conversationEvent.workspaceId
1889
+ });
1890
+ }
1482
1891
  const supervisor = input.state.supervisors.get(input.deviceId);
1483
1892
  if (!supervisor || supervisor.socket.readyState !== WEBSOCKET_OPEN) {
1484
1893
  input.reply.status(503).send({
@@ -1653,6 +2062,14 @@ function requireRelayUser(request, reply, store, options = {}) {
1653
2062
  });
1654
2063
  return null;
1655
2064
  }
2065
+ if (!options.admin && session.user.role === "admin") {
2066
+ reply.status(403).send({
2067
+ code: "forbidden",
2068
+ message: "Use the relay admin panel for this account."
2069
+ });
2070
+ return null;
2071
+ }
2072
+ store.recordUserSeen(session.user.id);
1656
2073
  request.relayUser = session.user;
1657
2074
  return session.user;
1658
2075
  }
@@ -1677,11 +2094,199 @@ function connectionStatus(state) {
1677
2094
  statuses.set(deviceId, {
1678
2095
  connected: true,
1679
2096
  connectedAt: supervisor.connectedAt,
1680
- lastHeartbeatAt: supervisor.lastHeartbeatAt
2097
+ lastHeartbeatAt: supervisor.lastHeartbeatAt,
2098
+ ipAddress: supervisor.ipAddress ?? null
1681
2099
  });
1682
2100
  }
1683
2101
  return statuses;
1684
2102
  }
2103
+ async function enrichAdminSummary(summary, state, store, conversationWindowDays) {
2104
+ const workspacesByDeviceId = /* @__PURE__ */ new Map();
2105
+ const threadsByDeviceId = /* @__PURE__ */ new Map();
2106
+ await Promise.all(
2107
+ summary.devices.map(async (device) => {
2108
+ const supervisor = state.supervisors.get(device.id);
2109
+ if (!supervisor || supervisor.socket.readyState !== WEBSOCKET_OPEN) {
2110
+ return;
2111
+ }
2112
+ const [workspaces, threads] = await Promise.all([
2113
+ fetchRelayWorkspaces(supervisor, device.id),
2114
+ fetchRelayThreads(supervisor, device.id)
2115
+ ]);
2116
+ workspacesByDeviceId.set(device.id, workspaces);
2117
+ const workspaceLabelById = new Map(workspaces.map((workspace) => [workspace.id, workspace.label]));
2118
+ threadsByDeviceId.set(
2119
+ device.id,
2120
+ threads.map((thread) => ({
2121
+ ...thread,
2122
+ workspaceLabel: thread.workspaceId ? workspaceLabelById.get(thread.workspaceId) ?? thread.workspaceLabel : null
2123
+ }))
2124
+ );
2125
+ })
2126
+ );
2127
+ const enriched = store.adminSummary(connectionStatus(state), {
2128
+ metadata: {
2129
+ workspacesByDeviceId,
2130
+ threadsByDeviceId
2131
+ },
2132
+ ...conversationWindowDays !== void 0 ? { conversationWindowDays } : {}
2133
+ });
2134
+ return {
2135
+ ...enriched,
2136
+ shares: enriched.shares.map((share) => {
2137
+ const thread = threadsByDeviceId.get(share.deviceId)?.find((item) => item.id === share.threadId);
2138
+ const workspace = share.workspaceId ? workspacesByDeviceId.get(share.deviceId)?.find((item) => item.id === share.workspaceId) : null;
2139
+ return {
2140
+ ...share,
2141
+ threadTitle: thread?.title ?? share.threadTitle,
2142
+ workspaceLabel: workspace?.label ?? thread?.workspaceLabel ?? share.workspaceLabel
2143
+ };
2144
+ })
2145
+ };
2146
+ }
2147
+ async function fetchRelayWorkspaces(supervisor, deviceId) {
2148
+ const payload = await forwardSupervisorJson(supervisor, deviceId, "/api/workspaces");
2149
+ const rows = Array.isArray(payload) ? payload : [];
2150
+ const workspaces = [];
2151
+ for (const workspace of rows.filter(isObject)) {
2152
+ const id = stringField(workspace, "id");
2153
+ const label = stringField(workspace, "label");
2154
+ if (!id || !label) {
2155
+ continue;
2156
+ }
2157
+ workspaces.push({
2158
+ id,
2159
+ label,
2160
+ absPath: stringField(workspace, "absPath")
2161
+ });
2162
+ }
2163
+ return workspaces.slice(0, 50);
2164
+ }
2165
+ async function fetchRelayThreads(supervisor, deviceId) {
2166
+ const payload = await forwardSupervisorJson(supervisor, deviceId, "/api/threads");
2167
+ const rows = Array.isArray(payload) ? payload : [];
2168
+ const threads = [];
2169
+ for (const thread of rows.filter(isObject)) {
2170
+ const id = stringField(thread, "id");
2171
+ if (!id) {
2172
+ continue;
2173
+ }
2174
+ threads.push({
2175
+ id,
2176
+ title: stringField(thread, "title") ?? "Untitled thread",
2177
+ workspaceId: stringField(thread, "workspaceId"),
2178
+ workspaceLabel: null,
2179
+ status: stringField(thread, "status"),
2180
+ updatedAt: stringField(thread, "updatedAt") ?? stringField(thread, "createdAt")
2181
+ });
2182
+ }
2183
+ return threads.slice(0, 80);
2184
+ }
2185
+ async function enrichPortalSummary(portal, state) {
2186
+ const threadCache = /* @__PURE__ */ new Map();
2187
+ const workspaceCache = /* @__PURE__ */ new Map();
2188
+ const enrichShare = async (share) => {
2189
+ const supervisor = state.supervisors.get(share.deviceId);
2190
+ if (!supervisor || supervisor.socket.readyState !== WEBSOCKET_OPEN) {
2191
+ return share;
2192
+ }
2193
+ const threadCacheKey = `${share.deviceId}:${share.threadId}`;
2194
+ let threadTitlePromise = threadCache.get(threadCacheKey);
2195
+ if (!threadTitlePromise) {
2196
+ threadTitlePromise = fetchRelayThreadTitle(supervisor, share.deviceId, share.threadId);
2197
+ threadCache.set(threadCacheKey, threadTitlePromise);
2198
+ }
2199
+ let workspaceLabelPromise = Promise.resolve(null);
2200
+ if (share.workspaceId) {
2201
+ const workspaceCacheKey = `${share.deviceId}:${share.workspaceId}`;
2202
+ const cached = workspaceCache.get(workspaceCacheKey);
2203
+ if (cached) {
2204
+ workspaceLabelPromise = cached;
2205
+ } else {
2206
+ workspaceLabelPromise = fetchRelayWorkspaceLabel(
2207
+ supervisor,
2208
+ share.deviceId,
2209
+ share.workspaceId
2210
+ );
2211
+ workspaceCache.set(workspaceCacheKey, workspaceLabelPromise);
2212
+ }
2213
+ }
2214
+ const [threadTitle, workspaceLabel] = await Promise.all([
2215
+ threadTitlePromise,
2216
+ workspaceLabelPromise
2217
+ ]);
2218
+ return {
2219
+ ...share,
2220
+ threadTitle,
2221
+ workspaceLabel
2222
+ };
2223
+ };
2224
+ const [sharedWithMe, sharedByMe] = await Promise.all([
2225
+ Promise.all(portal.sharedWithMe.map(enrichShare)),
2226
+ Promise.all(portal.sharedByMe.map(enrichShare))
2227
+ ]);
2228
+ return {
2229
+ ...portal,
2230
+ sharedWithMe,
2231
+ sharedByMe
2232
+ };
2233
+ }
2234
+ async function fetchRelayThreadTitle(supervisor, deviceId, threadId) {
2235
+ const payload = await forwardSupervisorJson(
2236
+ supervisor,
2237
+ deviceId,
2238
+ `/api/threads/${encodeURIComponent(threadId)}?limit=1`
2239
+ );
2240
+ const thread = isObject(payload) && isObject(payload.thread) ? payload.thread : payload;
2241
+ return stringField(thread, "title");
2242
+ }
2243
+ async function fetchRelayWorkspaceLabel(supervisor, deviceId, workspaceId) {
2244
+ const payload = await forwardSupervisorJson(supervisor, deviceId, `/api/workspaces/${encodeURIComponent(workspaceId)}`);
2245
+ return stringField(payload, "label");
2246
+ }
2247
+ async function forwardSupervisorJson(supervisor, deviceId, targetPath) {
2248
+ try {
2249
+ const response = await supervisor.requestBroker.forward(
2250
+ supervisor.socket,
2251
+ {
2252
+ type: "relay.request",
2253
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2254
+ requestId: randomUUID(),
2255
+ deviceId,
2256
+ payload: {
2257
+ method: "GET",
2258
+ path: targetPath,
2259
+ headers: {},
2260
+ body: null
2261
+ }
2262
+ },
2263
+ { timeoutMs: RELAY_PORTAL_METADATA_TIMEOUT_MS }
2264
+ );
2265
+ if (response.statusCode < 200 || response.statusCode >= 300) {
2266
+ return null;
2267
+ }
2268
+ const body = relayJsonBody(response);
2269
+ return JSON.parse(body);
2270
+ } catch {
2271
+ return null;
2272
+ }
2273
+ }
2274
+ function relayJsonBody(response) {
2275
+ if (response.bodyEncoding === "base64") {
2276
+ return Buffer.from(response.body, "base64").toString("utf8");
2277
+ }
2278
+ return response.body;
2279
+ }
2280
+ function isObject(value) {
2281
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
2282
+ }
2283
+ function stringField(value, field) {
2284
+ if (!isObject(value)) {
2285
+ return null;
2286
+ }
2287
+ const fieldValue = value[field];
2288
+ return typeof fieldValue === "string" && fieldValue.trim().length > 0 ? fieldValue : null;
2289
+ }
1685
2290
  function relayAccessDto(access) {
1686
2291
  return {
1687
2292
  kind: access.kind,
@@ -1713,6 +2318,39 @@ function workspaceIdFromPath(pathValue) {
1713
2318
  const match = /^\/api\/workspaces\/([^/?#]+)/.exec(pathname);
1714
2319
  return match ? decodeURIComponent(match[1]) : null;
1715
2320
  }
2321
+ function conversationEventFromRequest(method, pathValue, body) {
2322
+ if (method.toUpperCase() !== "POST") {
2323
+ return null;
2324
+ }
2325
+ const pathname = new URL(pathValue, "http://relay.local").pathname;
2326
+ if (pathname === "/api/threads/start") {
2327
+ return {
2328
+ threadId: null,
2329
+ workspaceId: isObject(body) && typeof body.workspaceId === "string" ? body.workspaceId : null
2330
+ };
2331
+ }
2332
+ const promptMatch = /^\/api\/threads\/([^/?#]+)\/prompt$/.exec(pathname);
2333
+ if (promptMatch) {
2334
+ return {
2335
+ threadId: decodeURIComponent(promptMatch[1]),
2336
+ workspaceId: null
2337
+ };
2338
+ }
2339
+ return null;
2340
+ }
2341
+ function relayClientIp(request) {
2342
+ const forwarded = firstHeaderValue(request.headers["cf-connecting-ip"]) ?? firstHeaderValue(request.headers["x-real-ip"]) ?? firstHeaderValue(request.headers["x-forwarded-for"]);
2343
+ if (forwarded) {
2344
+ return forwarded.split(",")[0]?.trim() || forwarded;
2345
+ }
2346
+ return request.ip || null;
2347
+ }
2348
+ function firstHeaderValue(value) {
2349
+ if (Array.isArray(value)) {
2350
+ return value[0];
2351
+ }
2352
+ return value;
2353
+ }
1716
2354
  function isAllowedForRelayAccess(access, method, pathValue) {
1717
2355
  if (access.kind === "owner") {
1718
2356
  return true;
@@ -1751,16 +2389,36 @@ function isAllowedSharedThreadPath(access, methodName, pathname, threadId) {
1751
2389
  if (access.threadAccess !== "control") {
1752
2390
  return false;
1753
2391
  }
2392
+ const controlReadPatterns = [
2393
+ new RegExp(`^/api/threads/${escapedThreadId}/fork-turns$`)
2394
+ ];
2395
+ if (methodName === "GET") {
2396
+ return controlReadPatterns.some((pattern) => pattern.test(pathname));
2397
+ }
1754
2398
  const controlPatterns = [
1755
2399
  new RegExp(`^/api/threads/${escapedThreadId}/goal$`),
1756
2400
  new RegExp(`^/api/threads/${escapedThreadId}/resume$`),
1757
2401
  new RegExp(`^/api/threads/${escapedThreadId}/prompt$`),
1758
2402
  new RegExp(`^/api/threads/${escapedThreadId}/interrupt$`),
2403
+ new RegExp(`^/api/threads/${escapedThreadId}/compact$`),
2404
+ new RegExp(`^/api/threads/${escapedThreadId}/fork$`),
2405
+ new RegExp(`^/api/threads/${escapedThreadId}/hooks$`),
2406
+ new RegExp(`^/api/threads/${escapedThreadId}/hooks/trust$`),
2407
+ new RegExp(`^/api/threads/${escapedThreadId}/hooks/untrust$`),
1759
2408
  new RegExp(`^/api/threads/${escapedThreadId}/requests/[^/]+/respond$`)
1760
2409
  ];
1761
2410
  if (methodName === "PATCH") {
2411
+ return [
2412
+ new RegExp(`^/api/threads/${escapedThreadId}/goal$`),
2413
+ new RegExp(`^/api/threads/${escapedThreadId}/settings$`)
2414
+ ].some((pattern) => pattern.test(pathname));
2415
+ }
2416
+ if (methodName === "DELETE") {
1762
2417
  return new RegExp(`^/api/threads/${escapedThreadId}/goal$`).test(pathname);
1763
2418
  }
2419
+ if (methodName === "PUT") {
2420
+ return new RegExp(`^/api/threads/${escapedThreadId}/hooks$`).test(pathname);
2421
+ }
1764
2422
  if (methodName === "POST") {
1765
2423
  return controlPatterns.some((pattern) => pattern.test(pathname));
1766
2424
  }