remote-codex 0.11.23 → 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);
@@ -203,7 +251,7 @@ var RelayStore = class _RelayStore {
203
251
  ).get(ownerUserId, target.id, input.deviceId, input.threadId)
204
252
  );
205
253
  if (existing) {
206
- return this.updateShare(existing.id, {
254
+ return this.updateShareRecord(existing.id, {
207
255
  label: input.label?.trim() || null,
208
256
  workspaceId: input.workspaceId?.trim() || null,
209
257
  threadAccess: normalizeThreadAccess(input.threadAccess),
@@ -220,17 +268,39 @@ 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),
227
277
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
228
278
  revokedAt: null,
229
- expiresAt: normalizeExpiresAt(input.expiresAt)
279
+ expiresAt: normalizeExpiresAt(input.expiresAt),
280
+ lastAccessedAt: null,
281
+ lastAccessedByUsername: null,
282
+ accessEvents: []
230
283
  };
231
284
  this.insertShare(share);
232
285
  return share;
233
286
  }
287
+ updateShare(userId, shareId, input) {
288
+ const share = this.rowToShare(
289
+ this.sqlite.prepare("SELECT * FROM relay_shares WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL").get(shareId, userId)
290
+ );
291
+ if (!share) {
292
+ throw new RelayStoreError(404, "not_found", "Share was not found.");
293
+ }
294
+ return this.publicShare(
295
+ this.updateShareRecord(shareId, {
296
+ label: input.label !== void 0 ? input.label?.trim() || null : share.label,
297
+ workspaceId: input.workspaceId !== void 0 ? input.workspaceId?.trim() || null : share.workspaceId,
298
+ threadAccess: input.threadAccess !== void 0 ? normalizeThreadAccess(input.threadAccess) : share.threadAccess,
299
+ workspaceAccess: input.workspaceAccess !== void 0 ? normalizeWorkspaceAccess(input.workspaceAccess) : share.workspaceAccess,
300
+ expiresAt: input.expiresAt !== void 0 ? normalizeExpiresAt(input.expiresAt) : share.expiresAt
301
+ })
302
+ );
303
+ }
234
304
  revokeShare(userId, shareId) {
235
305
  const share = this.rowToShare(
236
306
  this.sqlite.prepare("SELECT * FROM relay_shares WHERE id = ? AND owner_user_id = ?").get(shareId, userId)
@@ -333,12 +403,32 @@ var RelayStore = class _RelayStore {
333
403
  sharedByMe: sharedByMe.map((share) => this.publicShare(share))
334
404
  };
335
405
  }
336
- 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);
337
415
  return {
338
- users: this.getUsers().map((user) => this.publicUser(user)),
339
- devices: this.getDevices().map(
340
- (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)
341
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,
342
432
  registrationEnabled: this.registrationEnabled()
343
433
  };
344
434
  }
@@ -346,6 +436,95 @@ var RelayStore = class _RelayStore {
346
436
  this.setSetting("registrationEnabled", enabled ? "true" : "false");
347
437
  return enabled;
348
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
+ }
349
528
  setUserEnabled(userId, enabled) {
350
529
  const user = this.requireUser(userId);
351
530
  if (user.role === "admin" && !enabled) {
@@ -354,6 +533,30 @@ var RelayStore = class _RelayStore {
354
533
  this.sqlite.prepare("UPDATE relay_users SET enabled = ? WHERE id = ?").run(enabled ? 1 : 0, userId);
355
534
  return this.publicUser({ ...user, enabled });
356
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
+ }
357
560
  updateAccount(userId, input) {
358
561
  const user = this.requireUser(userId);
359
562
  const username = input.username !== void 0 ? normalizeUsername(input.username) : user.username;
@@ -404,15 +607,51 @@ var RelayStore = class _RelayStore {
404
607
  createdAt: device.createdAt
405
608
  };
406
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
+ }
628
+ recordShareAccess(share, user) {
629
+ if (share.revokedAt || share.expiresAt && share.expiresAt <= (/* @__PURE__ */ new Date()).toISOString()) {
630
+ return;
631
+ }
632
+ const accessedAt = (/* @__PURE__ */ new Date()).toISOString();
633
+ this.sqlite.prepare(
634
+ `
635
+ INSERT INTO relay_share_access_events (
636
+ id, share_id, user_id, username, accessed_at
637
+ ) VALUES (?, ?, ?, ?, ?)
638
+ `
639
+ ).run(crypto.randomUUID(), share.id, user.id, user.username, accessedAt);
640
+ }
407
641
  publicShare(share) {
408
642
  const owner = this.getUser(share.ownerUserId);
409
643
  const target = this.getUser(share.targetUserId);
410
644
  const device = this.getDevice(share.deviceId);
645
+ const accessEvents = this.getShareAccessEvents(share.id);
646
+ const lastAccess = accessEvents[0] ?? null;
411
647
  return {
412
648
  ...share,
413
649
  ownerUsername: share.ownerUsername ?? owner?.username ?? "unknown",
414
650
  targetUsername: share.targetUsername ?? target?.username ?? "unknown",
415
- deviceName: share.deviceName ?? device?.name ?? "Remote Codex device"
651
+ deviceName: share.deviceName ?? device?.name ?? "Remote Codex device",
652
+ lastAccessedAt: lastAccess?.accessedAt ?? null,
653
+ lastAccessedByUsername: lastAccess?.username ?? null,
654
+ accessEvents
416
655
  };
417
656
  }
418
657
  migrate() {
@@ -428,6 +667,7 @@ var RelayStore = class _RelayStore {
428
667
  username TEXT NOT NULL UNIQUE,
429
668
  role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
430
669
  enabled INTEGER NOT NULL DEFAULT 1,
670
+ last_seen_at TEXT,
431
671
  created_at TEXT NOT NULL,
432
672
  password_salt TEXT NOT NULL,
433
673
  password_hash TEXT NOT NULL
@@ -466,7 +706,43 @@ var RelayStore = class _RelayStore {
466
706
  CREATE INDEX IF NOT EXISTS relay_shares_owner_idx ON relay_shares(owner_user_id);
467
707
  CREATE INDEX IF NOT EXISTS relay_shares_target_idx ON relay_shares(target_user_id);
468
708
  CREATE INDEX IF NOT EXISTS relay_shares_device_thread_idx ON relay_shares(device_id, thread_id);
709
+
710
+ CREATE TABLE IF NOT EXISTS relay_share_access_events (
711
+ id TEXT PRIMARY KEY,
712
+ share_id TEXT NOT NULL REFERENCES relay_shares(id) ON DELETE CASCADE,
713
+ user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
714
+ username TEXT NOT NULL,
715
+ accessed_at TEXT NOT NULL
716
+ );
717
+
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);
469
744
  `);
745
+ this.ensureColumn("relay_users", "last_seen_at", "TEXT");
470
746
  this.ensureColumn("relay_devices", "token", "TEXT");
471
747
  this.ensureColumn("relay_shares", "workspace_id", "TEXT");
472
748
  this.ensureColumn("relay_shares", "thread_access", "TEXT NOT NULL DEFAULT 'control'");
@@ -526,6 +802,9 @@ var RelayStore = class _RelayStore {
526
802
  setSetting(key, value) {
527
803
  this.sqlite.prepare("INSERT INTO relay_settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value);
528
804
  }
805
+ deleteSetting(key) {
806
+ this.sqlite.prepare("DELETE FROM relay_settings WHERE key = ?").run(key);
807
+ }
529
808
  createStoredUser(input) {
530
809
  const email = input.email.trim().toLowerCase();
531
810
  const username = normalizeUsername(input.username);
@@ -549,6 +828,7 @@ var RelayStore = class _RelayStore {
549
828
  role: input.role,
550
829
  enabled: true,
551
830
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
831
+ lastSeenAt: null,
552
832
  passwordSalt,
553
833
  passwordHash: hashSecret(input.password, passwordSalt)
554
834
  };
@@ -614,8 +894,8 @@ var RelayStore = class _RelayStore {
614
894
  this.sqlite.prepare(
615
895
  `
616
896
  INSERT INTO relay_users (
617
- id, email, username, role, enabled, created_at, password_salt, password_hash
618
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
897
+ id, email, username, role, enabled, last_seen_at, created_at, password_salt, password_hash
898
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
619
899
  `
620
900
  ).run(
621
901
  user.id,
@@ -623,6 +903,7 @@ var RelayStore = class _RelayStore {
623
903
  user.username,
624
904
  user.role,
625
905
  user.enabled ? 1 : 0,
906
+ user.lastSeenAt,
626
907
  user.createdAt,
627
908
  user.passwordSalt,
628
909
  user.passwordHash
@@ -672,7 +953,23 @@ var RelayStore = class _RelayStore {
672
953
  share.expiresAt
673
954
  );
674
955
  }
675
- updateShare(shareId, input) {
956
+ getShareAccessEvents(shareId) {
957
+ return this.sqlite.prepare(
958
+ `
959
+ SELECT * FROM relay_share_access_events
960
+ WHERE share_id = ?
961
+ ORDER BY accessed_at DESC
962
+ LIMIT 8
963
+ `
964
+ ).all(shareId).map((row) => ({
965
+ id: row.id,
966
+ shareId: row.share_id,
967
+ userId: row.user_id,
968
+ username: row.username,
969
+ accessedAt: row.accessed_at
970
+ }));
971
+ }
972
+ updateShareRecord(shareId, input) {
676
973
  this.sqlite.prepare(
677
974
  `
678
975
  UPDATE relay_shares
@@ -726,6 +1023,72 @@ var RelayStore = class _RelayStore {
726
1023
  getSharesByTarget(targetUserId) {
727
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));
728
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
+ }
729
1092
  rowToUser(row) {
730
1093
  if (!row) return null;
731
1094
  return {
@@ -735,6 +1098,7 @@ var RelayStore = class _RelayStore {
735
1098
  role: row.role,
736
1099
  enabled: Boolean(row.enabled),
737
1100
  createdAt: row.created_at,
1101
+ lastSeenAt: row.last_seen_at ?? null,
738
1102
  passwordSalt: row.password_salt,
739
1103
  passwordHash: row.password_hash
740
1104
  };
@@ -762,13 +1126,32 @@ var RelayStore = class _RelayStore {
762
1126
  deviceId: row.device_id,
763
1127
  deviceName: row.device_name ?? "Remote Codex device",
764
1128
  threadId: row.thread_id,
1129
+ threadTitle: null,
765
1130
  workspaceId: row.workspace_id ?? null,
1131
+ workspaceLabel: null,
766
1132
  label: row.label,
767
1133
  threadAccess: normalizeThreadAccess(row.thread_access),
768
1134
  workspaceAccess: normalizeWorkspaceAccess(row.workspace_access),
769
1135
  createdAt: row.created_at,
770
1136
  revokedAt: row.revoked_at,
771
- expiresAt: row.expires_at ?? null
1137
+ expiresAt: row.expires_at ?? null,
1138
+ lastAccessedAt: null,
1139
+ lastAccessedByUsername: null,
1140
+ accessEvents: []
1141
+ };
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
772
1155
  };
773
1156
  }
774
1157
  };
@@ -800,6 +1183,12 @@ function normalizeExpiresAt(value) {
800
1183
  const timestamp = Date.parse(value);
801
1184
  return Number.isNaN(timestamp) ? null : new Date(timestamp).toISOString();
802
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
+ }
803
1192
  function hashSecret(secret, salt) {
804
1193
  return crypto.scryptSync(secret, salt, 32).toString("base64url");
805
1194
  }
@@ -823,6 +1212,7 @@ function safeEqual(left, right) {
823
1212
 
824
1213
  // src/app.ts
825
1214
  var RELAY_REQUEST_TIMEOUT_MS = 3e4;
1215
+ var RELAY_PORTAL_METADATA_TIMEOUT_MS = 900;
826
1216
  var WEBSOCKET_OPEN = 1;
827
1217
  var RELAY_COOKIE_NAME = "remote_codex_relay_session";
828
1218
  var threadAccessSchema = z.enum(["read", "control"]);
@@ -854,9 +1244,27 @@ var createShareSchema = z.object({
854
1244
  message: "targetIdentifier is required.",
855
1245
  path: ["targetIdentifier"]
856
1246
  });
1247
+ var updateShareSchema = z.object({
1248
+ workspaceId: z.string().uuid().nullable().optional(),
1249
+ label: z.string().trim().min(1).max(160).nullable().optional(),
1250
+ threadAccess: threadAccessSchema.optional(),
1251
+ workspaceAccess: workspaceAccessSchema.optional(),
1252
+ expiresAt: z.string().datetime().nullable().optional()
1253
+ });
857
1254
  var setEnabledSchema = z.object({
858
1255
  enabled: z.boolean()
859
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
+ });
860
1268
  var updateAccountSchema = z.object({
861
1269
  username: z.string().trim().min(3).optional()
862
1270
  });
@@ -888,6 +1296,58 @@ var WEBVIEW_CORS_ALLOW_METHODS = [
888
1296
  "DELETE",
889
1297
  "OPTIONS"
890
1298
  ].join(", ");
1299
+ var RELAY_REQUEST_HEADER_BLOCKLIST = /* @__PURE__ */ new Set([
1300
+ "authorization",
1301
+ "connection",
1302
+ "content-length",
1303
+ "cookie",
1304
+ "expect",
1305
+ "forwarded",
1306
+ "host",
1307
+ "keep-alive",
1308
+ "origin",
1309
+ "proxy-authenticate",
1310
+ "proxy-authorization",
1311
+ "proxy-connection",
1312
+ "referer",
1313
+ "referrer",
1314
+ "set-cookie",
1315
+ "te",
1316
+ "trailer",
1317
+ "transfer-encoding",
1318
+ "upgrade",
1319
+ "via",
1320
+ "x-client-ip",
1321
+ "x-forwarded-for",
1322
+ "x-forwarded-host",
1323
+ "x-forwarded-port",
1324
+ "x-forwarded-proto",
1325
+ "x-forwarded-protocol",
1326
+ "x-forwarded-scheme",
1327
+ "x-real-ip",
1328
+ "x-remote-codex-relay-forwarded"
1329
+ ]);
1330
+ var RELAY_RESPONSE_HEADER_BLOCKLIST = /* @__PURE__ */ new Set([
1331
+ "access-control-allow-credentials",
1332
+ "access-control-allow-headers",
1333
+ "access-control-allow-methods",
1334
+ "access-control-allow-origin",
1335
+ "access-control-expose-headers",
1336
+ "access-control-max-age",
1337
+ "access-control-request-headers",
1338
+ "access-control-request-method",
1339
+ "connection",
1340
+ "content-length",
1341
+ "keep-alive",
1342
+ "location",
1343
+ "proxy-authenticate",
1344
+ "refresh",
1345
+ "set-cookie",
1346
+ "te",
1347
+ "trailer",
1348
+ "transfer-encoding",
1349
+ "upgrade"
1350
+ ]);
891
1351
  function buildRelayServer(config2, options = {}) {
892
1352
  const app2 = Fastify({ logger: false });
893
1353
  app2.addContentTypeParser("*", { parseAs: "buffer" }, (_request, body, done) => {
@@ -901,6 +1361,7 @@ function buildRelayServer(config2, options = {}) {
901
1361
  if (config2.registrationEnabledConfigured) {
902
1362
  store.setRegistrationEnabled(config2.registrationEnabled);
903
1363
  }
1364
+ store.ensureRegistrationPassword(config2.registrationPassword);
904
1365
  store.seedAdmin({
905
1366
  username: config2.adminUsername,
906
1367
  email: config2.adminEmail,
@@ -938,21 +1399,35 @@ function buildRelayServer(config2, options = {}) {
938
1399
  });
939
1400
  app2.post("/relay/auth/register", async (request, reply) => {
940
1401
  const body = registerSchema.parse(request.body ?? {});
941
- if (config2.registrationPassword && body.registrationPassword !== config2.registrationPassword) {
1402
+ const settings = store.registrationSettings();
1403
+ if (settings.registrationPassword && body.registrationPassword !== settings.registrationPassword) {
942
1404
  reply.status(403).send({
943
1405
  code: "forbidden",
944
1406
  message: "Invalid registration password."
945
1407
  });
946
1408
  return;
947
1409
  }
948
- 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
+ }
949
1422
  const result = store.register(registerInput);
1423
+ store.recordUserSeen(result.session.user.id);
950
1424
  attachRelayCookie(reply, result.token);
951
1425
  return result;
952
1426
  });
953
1427
  app2.post("/relay/auth/login", async (request, reply) => {
954
1428
  const body = loginSchema.parse(request.body ?? {});
955
1429
  const result = store.login(body);
1430
+ store.recordUserSeen(result.session.user.id);
956
1431
  attachRelayCookie(reply, result.token);
957
1432
  return result;
958
1433
  });
@@ -983,7 +1458,7 @@ function buildRelayServer(config2, options = {}) {
983
1458
  if (!user) {
984
1459
  return;
985
1460
  }
986
- return store.portalSummary(user.id, connectionStatus(state));
1461
+ return enrichPortalSummary(store.portalSummary(user.id, connectionStatus(state)), state);
987
1462
  });
988
1463
  app2.get("/relay/access", async (request, reply) => {
989
1464
  const user = requireRelayUser(request, reply, store);
@@ -1038,6 +1513,15 @@ function buildRelayServer(config2, options = {}) {
1038
1513
  expiresAt: body.expiresAt ?? null
1039
1514
  });
1040
1515
  });
1516
+ app2.patch("/relay/shares/:shareId", async (request, reply) => {
1517
+ const user = requireRelayUser(request, reply, store);
1518
+ if (!user) {
1519
+ return;
1520
+ }
1521
+ const { shareId } = z.object({ shareId: z.string().uuid() }).parse(request.params);
1522
+ const body = updateShareSchema.parse(request.body ?? {});
1523
+ return store.updateShare(user.id, shareId, body);
1524
+ });
1041
1525
  app2.delete("/relay/shares/:shareId", async (request, reply) => {
1042
1526
  const user = requireRelayUser(request, reply, store);
1043
1527
  if (!user) {
@@ -1051,15 +1535,27 @@ function buildRelayServer(config2, options = {}) {
1051
1535
  if (!user) {
1052
1536
  return;
1053
1537
  }
1054
- 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);
1055
1543
  });
1056
1544
  app2.patch("/relay/admin/settings/registration", async (request, reply) => {
1057
1545
  const user = requireRelayUser(request, reply, store, { admin: true });
1058
1546
  if (!user) {
1059
1547
  return;
1060
1548
  }
1061
- const body = setEnabledSchema.parse(request.body ?? {});
1062
- 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
+ };
1063
1559
  });
1064
1560
  app2.patch("/relay/admin/users/:userId", async (request, reply) => {
1065
1561
  const user = requireRelayUser(request, reply, store, { admin: true });
@@ -1070,6 +1566,40 @@ function buildRelayServer(config2, options = {}) {
1070
1566
  const body = setEnabledSchema.parse(request.body ?? {});
1071
1567
  return store.setUserEnabled(userId, body.enabled);
1072
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
+ });
1073
1603
  app2.all("/relay/devices/:deviceId/api/*", async (request, reply) => {
1074
1604
  const user = requireRelayUser(request, reply, store);
1075
1605
  if (!user) {
@@ -1162,7 +1692,8 @@ function buildRelayServer(config2, options = {}) {
1162
1692
  clientSockets: /* @__PURE__ */ new Map(),
1163
1693
  connected: true,
1164
1694
  connectedAt,
1165
- lastHeartbeatAt: connectedAt
1695
+ lastHeartbeatAt: connectedAt,
1696
+ ipAddress: relayClientIp(request)
1166
1697
  };
1167
1698
  state.supervisors.set(deviceId, connection);
1168
1699
  socket.send(
@@ -1231,6 +1762,9 @@ function buildRelayServer(config2, options = {}) {
1231
1762
  socket.close(1013, "No supervisor is connected for this device.");
1232
1763
  return;
1233
1764
  }
1765
+ if (access.kind === "shared") {
1766
+ store.recordShareAccess(access.share, session.user);
1767
+ }
1234
1768
  connectRelayWebsocket(supervisor, socket, threadId, access);
1235
1769
  }
1236
1770
  });
@@ -1261,6 +1795,9 @@ function buildRelayServer(config2, options = {}) {
1261
1795
  socket.close(1008, "Device access is not allowed.");
1262
1796
  return;
1263
1797
  }
1798
+ if (access.kind === "shared") {
1799
+ store.recordShareAccess(access.share, session.user);
1800
+ }
1264
1801
  connectRelayWebsocket(supervisor, socket, threadId, access);
1265
1802
  }
1266
1803
  });
@@ -1335,6 +1872,22 @@ async function forwardRelayHttp(input) {
1335
1872
  });
1336
1873
  return;
1337
1874
  }
1875
+ if (access.kind === "shared") {
1876
+ input.store.recordShareAccess(access.share, input.user);
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
+ }
1338
1891
  const supervisor = input.state.supervisors.get(input.deviceId);
1339
1892
  if (!supervisor || supervisor.socket.readyState !== WEBSOCKET_OPEN) {
1340
1893
  input.reply.status(503).send({
@@ -1509,6 +2062,14 @@ function requireRelayUser(request, reply, store, options = {}) {
1509
2062
  });
1510
2063
  return null;
1511
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);
1512
2073
  request.relayUser = session.user;
1513
2074
  return session.user;
1514
2075
  }
@@ -1533,11 +2094,199 @@ function connectionStatus(state) {
1533
2094
  statuses.set(deviceId, {
1534
2095
  connected: true,
1535
2096
  connectedAt: supervisor.connectedAt,
1536
- lastHeartbeatAt: supervisor.lastHeartbeatAt
2097
+ lastHeartbeatAt: supervisor.lastHeartbeatAt,
2098
+ ipAddress: supervisor.ipAddress ?? null
1537
2099
  });
1538
2100
  }
1539
2101
  return statuses;
1540
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
+ }
1541
2290
  function relayAccessDto(access) {
1542
2291
  return {
1543
2292
  kind: access.kind,
@@ -1569,6 +2318,39 @@ function workspaceIdFromPath(pathValue) {
1569
2318
  const match = /^\/api\/workspaces\/([^/?#]+)/.exec(pathname);
1570
2319
  return match ? decodeURIComponent(match[1]) : null;
1571
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
+ }
1572
2354
  function isAllowedForRelayAccess(access, method, pathValue) {
1573
2355
  if (access.kind === "owner") {
1574
2356
  return true;
@@ -1607,16 +2389,36 @@ function isAllowedSharedThreadPath(access, methodName, pathname, threadId) {
1607
2389
  if (access.threadAccess !== "control") {
1608
2390
  return false;
1609
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
+ }
1610
2398
  const controlPatterns = [
1611
2399
  new RegExp(`^/api/threads/${escapedThreadId}/goal$`),
1612
2400
  new RegExp(`^/api/threads/${escapedThreadId}/resume$`),
1613
2401
  new RegExp(`^/api/threads/${escapedThreadId}/prompt$`),
1614
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$`),
1615
2408
  new RegExp(`^/api/threads/${escapedThreadId}/requests/[^/]+/respond$`)
1616
2409
  ];
1617
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") {
1618
2417
  return new RegExp(`^/api/threads/${escapedThreadId}/goal$`).test(pathname);
1619
2418
  }
2419
+ if (methodName === "PUT") {
2420
+ return new RegExp(`^/api/threads/${escapedThreadId}/hooks$`).test(pathname);
2421
+ }
1620
2422
  if (methodName === "POST") {
1621
2423
  return controlPatterns.some((pattern) => pattern.test(pathname));
1622
2424
  }
@@ -1678,7 +2480,7 @@ function relayRequestHeaders(headers) {
1678
2480
  const output = {};
1679
2481
  for (const [name, value] of Object.entries(headers)) {
1680
2482
  const lower = name.toLowerCase();
1681
- if (lower === "authorization" || lower === "content-length" || lower === "transfer-encoding") {
2483
+ if (RELAY_REQUEST_HEADER_BLOCKLIST.has(lower) || lower.startsWith("x-forwarded-")) {
1682
2484
  continue;
1683
2485
  }
1684
2486
  if (Array.isArray(value)) {
@@ -1691,7 +2493,7 @@ function relayRequestHeaders(headers) {
1691
2493
  }
1692
2494
  function canForwardResponseHeader(name) {
1693
2495
  const lower = name.toLowerCase();
1694
- return lower !== "content-length" && lower !== "transfer-encoding";
2496
+ return !RELAY_RESPONSE_HEADER_BLOCKLIST.has(lower);
1695
2497
  }
1696
2498
  function bearerToken(value) {
1697
2499
  const match = /^Bearer\s+(.+)$/i.exec(value ?? "");