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.
- package/apps/relay-server/dist/index.js +825 -23
- package/apps/supervisor-api/dist/index.js +68 -31
- package/apps/supervisor-web/dist/assets/index-Ch_S2n2I.css +1 -0
- package/apps/supervisor-web/dist/assets/index-JrzRGVx3.js +5 -0
- package/apps/supervisor-web/dist/assets/{thread-ui-jJyFqIuV.js → thread-ui-Ck4oSYRQ.js} +20 -20
- package/apps/supervisor-web/dist/index.html +3 -3
- package/package.json +1 -1
- package/packages/shared/src/index.ts +73 -4
- package/apps/supervisor-web/dist/assets/index-CtcCwgIc.js +0 -5
- package/apps/supervisor-web/dist/assets/index-PMKHoC-h.css +0 -1
|
@@ -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
|
-
},
|
|
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.
|
|
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:
|
|
339
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
1062
|
-
|
|
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
|
|
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
|
|
2496
|
+
return !RELAY_RESPONSE_HEADER_BLOCKLIST.has(lower);
|
|
1695
2497
|
}
|
|
1696
2498
|
function bearerToken(value) {
|
|
1697
2499
|
const match = /^Bearer\s+(.+)$/i.exec(value ?? "");
|