remote-codex 0.11.28 → 0.11.30

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.
@@ -284,6 +284,82 @@ var RelayStore = class _RelayStore {
284
284
  this.insertShare(share);
285
285
  return share;
286
286
  }
287
+ createGrant(ownerUserId, input) {
288
+ const owner = this.requireUser(ownerUserId);
289
+ const device = this.getDevice(input.deviceId);
290
+ if (!device || device.ownerUserId !== ownerUserId) {
291
+ throw new RelayStoreError(404, "not_found", "Device was not found.");
292
+ }
293
+ const target = this.getUserByIdentifier(input.targetIdentifier);
294
+ if (!target || !target.enabled) {
295
+ throw new RelayStoreError(404, "not_found", "Target user was not found.");
296
+ }
297
+ if (target.id === ownerUserId) {
298
+ throw new RelayStoreError(400, "bad_request", "You cannot share access with yourself.");
299
+ }
300
+ const scope = normalizeShareScope(input.scope);
301
+ const threadId = scope === "thread" ? input.threadId?.trim() || null : null;
302
+ const workspaceId = scope === "workspace" ? input.workspaceId?.trim() || null : null;
303
+ if (scope === "thread" && !threadId) {
304
+ throw new RelayStoreError(400, "bad_request", "threadId is required for thread grants.");
305
+ }
306
+ if (scope === "workspace" && !workspaceId) {
307
+ throw new RelayStoreError(400, "bad_request", "workspaceId is required for workspace grants.");
308
+ }
309
+ const existing = this.rowToGrant(
310
+ this.sqlite.prepare(
311
+ `
312
+ SELECT * FROM relay_access_grants
313
+ WHERE owner_user_id = ?
314
+ AND target_user_id = ?
315
+ AND device_id = ?
316
+ AND scope = ?
317
+ AND COALESCE(thread_id, '') = COALESCE(?, '')
318
+ AND COALESCE(workspace_id, '') = COALESCE(?, '')
319
+ AND revoked_at IS NULL
320
+ `
321
+ ).get(ownerUserId, target.id, input.deviceId, scope, threadId, workspaceId)
322
+ );
323
+ if (existing) {
324
+ return this.updateGrantRecord(existing.id, {
325
+ label: input.label?.trim() || null,
326
+ workspaceScope: normalizeWorkspaceScope(input.workspaceScope),
327
+ workspaceIds: normalizeWorkspaceIds(input.workspaceIds),
328
+ threadAccess: normalizeThreadAccess(input.threadAccess),
329
+ workspaceAccess: normalizeWorkspaceAccess(input.workspaceAccess),
330
+ canCreateThreads: Boolean(input.canCreateThreads),
331
+ expiresAt: normalizeExpiresAt(input.expiresAt)
332
+ });
333
+ }
334
+ const grant = {
335
+ id: crypto.randomUUID(),
336
+ ownerUserId,
337
+ ownerUsername: owner.username,
338
+ targetUserId: target.id,
339
+ targetUsername: target.username,
340
+ deviceId: input.deviceId,
341
+ deviceName: device.name,
342
+ scope,
343
+ threadId,
344
+ threadTitle: null,
345
+ workspaceId,
346
+ workspaceLabel: null,
347
+ workspaceScope: normalizeWorkspaceScope(input.workspaceScope),
348
+ workspaceIds: normalizeWorkspaceIds(input.workspaceIds),
349
+ label: input.label?.trim() || null,
350
+ threadAccess: normalizeThreadAccess(input.threadAccess),
351
+ workspaceAccess: normalizeWorkspaceAccess(input.workspaceAccess),
352
+ canCreateThreads: Boolean(input.canCreateThreads),
353
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
354
+ revokedAt: null,
355
+ expiresAt: normalizeExpiresAt(input.expiresAt),
356
+ lastAccessedAt: null,
357
+ lastAccessedByUsername: null,
358
+ accessEvents: []
359
+ };
360
+ this.insertGrant(grant);
361
+ return grant;
362
+ }
287
363
  updateShare(userId, shareId, input) {
288
364
  const share = this.rowToShare(
289
365
  this.sqlite.prepare("SELECT * FROM relay_shares WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL").get(shareId, userId)
@@ -312,15 +388,49 @@ var RelayStore = class _RelayStore {
312
388
  this.sqlite.prepare("UPDATE relay_shares SET revoked_at = ? WHERE id = ?").run(revokedAt, shareId);
313
389
  return { ...share, revokedAt };
314
390
  }
391
+ updateGrant(userId, grantId, input) {
392
+ const grant = this.rowToGrant(
393
+ this.sqlite.prepare("SELECT * FROM relay_access_grants WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL").get(grantId, userId)
394
+ );
395
+ if (!grant) {
396
+ throw new RelayStoreError(404, "not_found", "Grant was not found.");
397
+ }
398
+ return this.publicGrant(
399
+ this.updateGrantRecord(grantId, {
400
+ label: input.label !== void 0 ? input.label?.trim() || null : grant.label,
401
+ workspaceScope: input.workspaceScope !== void 0 ? normalizeWorkspaceScope(input.workspaceScope) : grant.workspaceScope,
402
+ workspaceIds: input.workspaceIds !== void 0 ? normalizeWorkspaceIds(input.workspaceIds) : grant.workspaceIds,
403
+ threadAccess: input.threadAccess !== void 0 ? normalizeThreadAccess(input.threadAccess) : grant.threadAccess,
404
+ workspaceAccess: input.workspaceAccess !== void 0 ? normalizeWorkspaceAccess(input.workspaceAccess) : grant.workspaceAccess,
405
+ canCreateThreads: input.canCreateThreads !== void 0 ? Boolean(input.canCreateThreads) : grant.canCreateThreads,
406
+ expiresAt: input.expiresAt !== void 0 ? normalizeExpiresAt(input.expiresAt) : grant.expiresAt
407
+ })
408
+ );
409
+ }
410
+ revokeGrant(userId, grantId) {
411
+ const grant = this.rowToGrant(
412
+ this.sqlite.prepare("SELECT * FROM relay_access_grants WHERE id = ? AND owner_user_id = ?").get(grantId, userId)
413
+ );
414
+ if (!grant) {
415
+ throw new RelayStoreError(404, "not_found", "Grant was not found.");
416
+ }
417
+ const revokedAt = (/* @__PURE__ */ new Date()).toISOString();
418
+ this.sqlite.prepare("UPDATE relay_access_grants SET revoked_at = ? WHERE id = ?").run(revokedAt, grantId);
419
+ return { ...grant, revokedAt };
420
+ }
315
421
  effectiveAccess(userId, deviceId, scope = {}) {
316
422
  const owned = this.sqlite.prepare("SELECT 1 FROM relay_devices WHERE id = ? AND owner_user_id = ?").get(deviceId, userId);
317
423
  if (owned) {
318
424
  return {
319
425
  kind: "owner",
320
426
  share: null,
427
+ grant: null,
428
+ scope: "owner",
321
429
  threadAccess: "control",
322
430
  workspaceAccess: "write",
323
- workspaceId: null
431
+ workspaceId: null,
432
+ workspaceScope: null,
433
+ canCreateThreads: true
324
434
  };
325
435
  }
326
436
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -339,19 +449,25 @@ var RelayStore = class _RelayStore {
339
449
  `
340
450
  ).get(userId, deviceId, scope.threadId, now)
341
451
  );
342
- if (!share) {
343
- return null;
344
- }
345
- if (scope.workspaceId && (!share.workspaceId || share.workspaceId !== scope.workspaceId || share.workspaceAccess === "none")) {
346
- return null;
452
+ if (share) {
453
+ if (scope.workspaceId && (!share.workspaceId || share.workspaceId !== scope.workspaceId || share.workspaceAccess === "none")) {
454
+ const grant3 = this.findBestGrant(userId, deviceId, scope);
455
+ return grant3 ? this.accessFromGrant(grant3) : null;
456
+ }
457
+ return {
458
+ kind: "shared",
459
+ share,
460
+ grant: this.grantFromShare(share),
461
+ scope: "thread",
462
+ threadAccess: share.threadAccess,
463
+ workspaceAccess: share.workspaceAccess,
464
+ workspaceId: share.workspaceId,
465
+ workspaceScope: "selected",
466
+ canCreateThreads: false
467
+ };
347
468
  }
348
- return {
349
- kind: "shared",
350
- share,
351
- threadAccess: share.threadAccess,
352
- workspaceAccess: share.workspaceAccess,
353
- workspaceId: share.workspaceId
354
- };
469
+ const grant2 = this.findBestGrant(userId, deviceId, scope);
470
+ return grant2 ? this.accessFromGrant(grant2) : null;
355
471
  }
356
472
  if (scope.workspaceId) {
357
473
  const share = this.rowToShare(
@@ -371,18 +487,24 @@ var RelayStore = class _RelayStore {
371
487
  `
372
488
  ).get(userId, deviceId, scope.workspaceId, now)
373
489
  );
374
- if (!share) {
375
- return null;
490
+ if (share) {
491
+ return {
492
+ kind: "shared",
493
+ share,
494
+ grant: this.grantFromShare(share),
495
+ scope: "thread",
496
+ threadAccess: share.threadAccess,
497
+ workspaceAccess: share.workspaceAccess,
498
+ workspaceId: share.workspaceId,
499
+ workspaceScope: "selected",
500
+ canCreateThreads: false
501
+ };
376
502
  }
377
- return {
378
- kind: "shared",
379
- share,
380
- threadAccess: share.threadAccess,
381
- workspaceAccess: share.workspaceAccess,
382
- workspaceId: share.workspaceId
383
- };
503
+ const grant2 = this.findBestGrant(userId, deviceId, scope);
504
+ return grant2 ? this.accessFromGrant(grant2) : null;
384
505
  }
385
- return null;
506
+ const grant = this.findBestGrant(userId, deviceId, scope);
507
+ return grant ? this.accessFromGrant(grant) : null;
386
508
  }
387
509
  canAccessDevice(userId, deviceId, threadId) {
388
510
  return Boolean(
@@ -396,11 +518,22 @@ var RelayStore = class _RelayStore {
396
518
  const devices = this.getDevicesByOwner(userId);
397
519
  const sharedWithMe = this.getSharesByTarget(userId);
398
520
  const sharedByMe = this.getSharesByOwner(userId);
521
+ const grantsWithMe = this.getGrantsByTarget(userId);
522
+ const grantsByMe = this.getGrantsByOwner(userId);
399
523
  return {
400
524
  user: this.publicUser(user),
401
525
  devices: devices.map((device) => this.publicDevice(device, connectedDevices.get(device.id) ?? null)),
402
526
  sharedWithMe: sharedWithMe.map((share) => this.publicShare(share)),
403
- sharedByMe: sharedByMe.map((share) => this.publicShare(share))
527
+ sharedByMe: sharedByMe.map((share) => this.publicShare(share)),
528
+ sharedDevicesWithMe: grantsWithMe.filter((grant) => grant.scope === "device").map((grant) => this.publicGrant(grant)),
529
+ sharedThreadsWithMe: [
530
+ ...sharedWithMe.map((share) => this.publicGrant(this.grantFromShare(share))),
531
+ ...grantsWithMe.filter((grant) => grant.scope !== "device").map((grant) => this.publicGrant(grant))
532
+ ],
533
+ grantsByMe: [
534
+ ...sharedByMe.map((share) => this.publicGrant(this.grantFromShare(share))),
535
+ ...grantsByMe.map((grant) => this.publicGrant(grant))
536
+ ]
404
537
  };
405
538
  }
406
539
  sharedThreadsForDevice(userId, deviceId) {
@@ -628,7 +761,7 @@ var RelayStore = class _RelayStore {
628
761
  threads: metadata?.threadsByDeviceId?.get(device.id) ?? []
629
762
  };
630
763
  }
631
- recordShareAccess(share, user) {
764
+ recordShareAccess(share, user, kind = "access") {
632
765
  if (share.revokedAt || share.expiresAt && share.expiresAt <= (/* @__PURE__ */ new Date()).toISOString()) {
633
766
  return;
634
767
  }
@@ -636,10 +769,23 @@ var RelayStore = class _RelayStore {
636
769
  this.sqlite.prepare(
637
770
  `
638
771
  INSERT INTO relay_share_access_events (
639
- id, share_id, user_id, username, accessed_at
640
- ) VALUES (?, ?, ?, ?, ?)
772
+ id, share_id, user_id, username, kind, accessed_at
773
+ ) VALUES (?, ?, ?, ?, ?, ?)
641
774
  `
642
- ).run(crypto.randomUUID(), share.id, user.id, user.username, accessedAt);
775
+ ).run(crypto.randomUUID(), share.id, user.id, user.username, kind, accessedAt);
776
+ }
777
+ recordGrantAccess(grant, user, kind = "access") {
778
+ if (grant.revokedAt || grant.expiresAt && grant.expiresAt <= (/* @__PURE__ */ new Date()).toISOString()) {
779
+ return;
780
+ }
781
+ const accessedAt = (/* @__PURE__ */ new Date()).toISOString();
782
+ this.sqlite.prepare(
783
+ `
784
+ INSERT INTO relay_access_grant_events (
785
+ id, grant_id, user_id, username, kind, accessed_at
786
+ ) VALUES (?, ?, ?, ?, ?, ?)
787
+ `
788
+ ).run(crypto.randomUUID(), grant.id, user.id, user.username, kind, accessedAt);
643
789
  }
644
790
  publicShare(share) {
645
791
  const owner = this.getUser(share.ownerUserId);
@@ -657,6 +803,22 @@ var RelayStore = class _RelayStore {
657
803
  accessEvents
658
804
  };
659
805
  }
806
+ publicGrant(grant) {
807
+ const owner = this.getUser(grant.ownerUserId);
808
+ const target = this.getUser(grant.targetUserId);
809
+ const device = this.getDevice(grant.deviceId);
810
+ const accessEvents = this.getGrantAccessEvents(grant.id);
811
+ const lastAccess = accessEvents[0] ?? null;
812
+ return {
813
+ ...grant,
814
+ ownerUsername: grant.ownerUsername ?? owner?.username ?? "unknown",
815
+ targetUsername: grant.targetUsername ?? target?.username ?? "unknown",
816
+ deviceName: grant.deviceName ?? device?.name ?? "Remote Codex device",
817
+ lastAccessedAt: lastAccess?.accessedAt ?? null,
818
+ lastAccessedByUsername: lastAccess?.username ?? null,
819
+ accessEvents
820
+ };
821
+ }
660
822
  migrate() {
661
823
  this.sqlite.exec(`
662
824
  CREATE TABLE IF NOT EXISTS relay_settings (
@@ -697,7 +859,9 @@ var RelayStore = class _RelayStore {
697
859
  device_id TEXT NOT NULL REFERENCES relay_devices(id) ON DELETE CASCADE,
698
860
  device_name TEXT,
699
861
  thread_id TEXT NOT NULL,
862
+ thread_title TEXT,
700
863
  workspace_id TEXT,
864
+ workspace_label TEXT,
701
865
  label TEXT,
702
866
  thread_access TEXT NOT NULL DEFAULT 'control',
703
867
  workspace_access TEXT NOT NULL DEFAULT 'none',
@@ -715,11 +879,51 @@ var RelayStore = class _RelayStore {
715
879
  share_id TEXT NOT NULL REFERENCES relay_shares(id) ON DELETE CASCADE,
716
880
  user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
717
881
  username TEXT NOT NULL,
882
+ kind TEXT NOT NULL DEFAULT 'access',
718
883
  accessed_at TEXT NOT NULL
719
884
  );
720
885
 
721
886
  CREATE INDEX IF NOT EXISTS relay_share_access_events_share_idx ON relay_share_access_events(share_id, accessed_at DESC);
722
887
 
888
+ CREATE TABLE IF NOT EXISTS relay_access_grants (
889
+ id TEXT PRIMARY KEY,
890
+ owner_user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
891
+ owner_username TEXT,
892
+ target_user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
893
+ target_username TEXT,
894
+ device_id TEXT NOT NULL REFERENCES relay_devices(id) ON DELETE CASCADE,
895
+ device_name TEXT,
896
+ scope TEXT NOT NULL CHECK (scope IN ('thread', 'workspace', 'device')),
897
+ thread_id TEXT,
898
+ thread_title TEXT,
899
+ workspace_id TEXT,
900
+ workspace_label TEXT,
901
+ workspace_scope TEXT NOT NULL DEFAULT 'all',
902
+ workspace_ids TEXT NOT NULL DEFAULT '[]',
903
+ label TEXT,
904
+ thread_access TEXT NOT NULL DEFAULT 'control',
905
+ workspace_access TEXT NOT NULL DEFAULT 'none',
906
+ can_create_threads INTEGER NOT NULL DEFAULT 0,
907
+ created_at TEXT NOT NULL,
908
+ revoked_at TEXT,
909
+ expires_at TEXT
910
+ );
911
+
912
+ CREATE INDEX IF NOT EXISTS relay_access_grants_owner_idx ON relay_access_grants(owner_user_id);
913
+ CREATE INDEX IF NOT EXISTS relay_access_grants_target_idx ON relay_access_grants(target_user_id);
914
+ CREATE INDEX IF NOT EXISTS relay_access_grants_device_scope_idx ON relay_access_grants(device_id, scope);
915
+
916
+ CREATE TABLE IF NOT EXISTS relay_access_grant_events (
917
+ id TEXT PRIMARY KEY,
918
+ grant_id TEXT NOT NULL REFERENCES relay_access_grants(id) ON DELETE CASCADE,
919
+ user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
920
+ username TEXT NOT NULL,
921
+ kind TEXT NOT NULL DEFAULT 'access',
922
+ accessed_at TEXT NOT NULL
923
+ );
924
+
925
+ CREATE INDEX IF NOT EXISTS relay_access_grant_events_grant_idx ON relay_access_grant_events(grant_id, accessed_at DESC);
926
+
723
927
  CREATE TABLE IF NOT EXISTS relay_conversation_events (
724
928
  id TEXT PRIMARY KEY,
725
929
  user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
@@ -747,10 +951,17 @@ var RelayStore = class _RelayStore {
747
951
  `);
748
952
  this.ensureColumn("relay_users", "last_seen_at", "TEXT");
749
953
  this.ensureColumn("relay_devices", "token", "TEXT");
954
+ this.ensureColumn("relay_shares", "thread_title", "TEXT");
750
955
  this.ensureColumn("relay_shares", "workspace_id", "TEXT");
956
+ this.ensureColumn("relay_shares", "workspace_label", "TEXT");
751
957
  this.ensureColumn("relay_shares", "thread_access", "TEXT NOT NULL DEFAULT 'control'");
752
958
  this.ensureColumn("relay_shares", "workspace_access", "TEXT NOT NULL DEFAULT 'none'");
753
959
  this.ensureColumn("relay_shares", "expires_at", "TEXT");
960
+ this.ensureColumn("relay_access_grants", "workspace_scope", "TEXT NOT NULL DEFAULT 'all'");
961
+ this.ensureColumn("relay_access_grants", "workspace_ids", "TEXT NOT NULL DEFAULT '[]'");
962
+ this.ensureColumn("relay_access_grants", "can_create_threads", "INTEGER NOT NULL DEFAULT 0");
963
+ this.ensureColumn("relay_share_access_events", "kind", "TEXT NOT NULL DEFAULT 'access'");
964
+ this.ensureColumn("relay_access_grant_events", "kind", "TEXT NOT NULL DEFAULT 'access'");
754
965
  }
755
966
  ensureColumn(table, column, definition) {
756
967
  const columns = this.sqlite.prepare(`PRAGMA table_info(${table})`).all();
@@ -934,9 +1145,9 @@ var RelayStore = class _RelayStore {
934
1145
  `
935
1146
  INSERT INTO relay_shares (
936
1147
  id, owner_user_id, owner_username, target_user_id, target_username,
937
- device_id, device_name, thread_id, workspace_id, label,
1148
+ device_id, device_name, thread_id, thread_title, workspace_id, workspace_label, label,
938
1149
  thread_access, workspace_access, created_at, revoked_at, expires_at
939
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1150
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
940
1151
  `
941
1152
  ).run(
942
1153
  share.id,
@@ -947,7 +1158,9 @@ var RelayStore = class _RelayStore {
947
1158
  share.deviceId,
948
1159
  share.deviceName,
949
1160
  share.threadId,
1161
+ share.threadTitle,
950
1162
  share.workspaceId,
1163
+ share.workspaceLabel,
951
1164
  share.label,
952
1165
  share.threadAccess,
953
1166
  share.workspaceAccess,
@@ -956,6 +1169,60 @@ var RelayStore = class _RelayStore {
956
1169
  share.expiresAt
957
1170
  );
958
1171
  }
1172
+ insertGrant(grant) {
1173
+ this.sqlite.prepare(
1174
+ `
1175
+ INSERT INTO relay_access_grants (
1176
+ id, owner_user_id, owner_username, target_user_id, target_username,
1177
+ device_id, device_name, scope, thread_id, thread_title, workspace_id,
1178
+ workspace_label, workspace_scope, workspace_ids, label, thread_access,
1179
+ workspace_access, can_create_threads, created_at, revoked_at, expires_at
1180
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1181
+ `
1182
+ ).run(
1183
+ grant.id,
1184
+ grant.ownerUserId,
1185
+ grant.ownerUsername,
1186
+ grant.targetUserId,
1187
+ grant.targetUsername,
1188
+ grant.deviceId,
1189
+ grant.deviceName,
1190
+ grant.scope,
1191
+ grant.threadId,
1192
+ grant.threadTitle,
1193
+ grant.workspaceId,
1194
+ grant.workspaceLabel,
1195
+ grant.workspaceScope,
1196
+ JSON.stringify(grant.workspaceIds),
1197
+ grant.label,
1198
+ grant.threadAccess,
1199
+ grant.workspaceAccess,
1200
+ grant.canCreateThreads ? 1 : 0,
1201
+ grant.createdAt,
1202
+ grant.revokedAt,
1203
+ grant.expiresAt
1204
+ );
1205
+ }
1206
+ updateShareMetadata(shareId, input) {
1207
+ const threadTitle = normalizeOptionalMetadata(input.threadTitle);
1208
+ const workspaceLabel = normalizeOptionalMetadata(input.workspaceLabel);
1209
+ if (threadTitle === void 0 && workspaceLabel === void 0) {
1210
+ return this.rowToShare(
1211
+ this.sqlite.prepare("SELECT * FROM relay_shares WHERE id = ?").get(shareId)
1212
+ );
1213
+ }
1214
+ this.sqlite.prepare(
1215
+ `
1216
+ UPDATE relay_shares
1217
+ SET thread_title = COALESCE(?, thread_title),
1218
+ workspace_label = COALESCE(?, workspace_label)
1219
+ WHERE id = ?
1220
+ `
1221
+ ).run(threadTitle ?? null, workspaceLabel ?? null, shareId);
1222
+ return this.rowToShare(
1223
+ this.sqlite.prepare("SELECT * FROM relay_shares WHERE id = ?").get(shareId)
1224
+ );
1225
+ }
959
1226
  getShareAccessEvents(shareId) {
960
1227
  return this.sqlite.prepare(
961
1228
  `
@@ -969,6 +1236,24 @@ var RelayStore = class _RelayStore {
969
1236
  shareId: row.share_id,
970
1237
  userId: row.user_id,
971
1238
  username: row.username,
1239
+ kind: normalizeAccessEventKind(row.kind),
1240
+ accessedAt: row.accessed_at
1241
+ }));
1242
+ }
1243
+ getGrantAccessEvents(grantId) {
1244
+ return this.sqlite.prepare(
1245
+ `
1246
+ SELECT * FROM relay_access_grant_events
1247
+ WHERE grant_id = ?
1248
+ ORDER BY accessed_at DESC
1249
+ LIMIT 8
1250
+ `
1251
+ ).all(grantId).map((row) => ({
1252
+ id: row.id,
1253
+ grantId: row.grant_id,
1254
+ userId: row.user_id,
1255
+ username: row.username,
1256
+ kind: normalizeAccessEventKind(row.kind),
972
1257
  accessedAt: row.accessed_at
973
1258
  }));
974
1259
  }
@@ -995,6 +1280,33 @@ var RelayStore = class _RelayStore {
995
1280
  this.sqlite.prepare("SELECT * FROM relay_shares WHERE id = ?").get(shareId)
996
1281
  );
997
1282
  }
1283
+ updateGrantRecord(grantId, input) {
1284
+ this.sqlite.prepare(
1285
+ `
1286
+ UPDATE relay_access_grants
1287
+ SET label = ?,
1288
+ workspace_scope = ?,
1289
+ workspace_ids = ?,
1290
+ thread_access = ?,
1291
+ workspace_access = ?,
1292
+ can_create_threads = ?,
1293
+ expires_at = ?
1294
+ WHERE id = ?
1295
+ `
1296
+ ).run(
1297
+ input.label,
1298
+ input.workspaceScope,
1299
+ JSON.stringify(input.workspaceIds),
1300
+ input.threadAccess,
1301
+ input.workspaceAccess,
1302
+ input.canCreateThreads ? 1 : 0,
1303
+ input.expiresAt,
1304
+ grantId
1305
+ );
1306
+ return this.rowToGrant(
1307
+ this.sqlite.prepare("SELECT * FROM relay_access_grants WHERE id = ?").get(grantId)
1308
+ );
1309
+ }
998
1310
  getUser(id) {
999
1311
  return this.rowToUser(this.sqlite.prepare("SELECT * FROM relay_users WHERE id = ?").get(id));
1000
1312
  }
@@ -1031,6 +1343,107 @@ var RelayStore = class _RelayStore {
1031
1343
  const rows = options.includeRevoked ? this.sqlite.prepare(sql).all() : this.sqlite.prepare(sql).all((/* @__PURE__ */ new Date()).toISOString());
1032
1344
  return rows.map((row) => this.rowToShare(row)).filter((share) => Boolean(share));
1033
1345
  }
1346
+ getGrantsByOwner(ownerUserId) {
1347
+ return this.sqlite.prepare("SELECT * FROM relay_access_grants WHERE owner_user_id = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at ASC").all(ownerUserId, (/* @__PURE__ */ new Date()).toISOString()).map((row) => this.rowToGrant(row)).filter((grant) => Boolean(grant));
1348
+ }
1349
+ getGrantsByTarget(targetUserId) {
1350
+ return this.sqlite.prepare("SELECT * FROM relay_access_grants 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.rowToGrant(row)).filter((grant) => Boolean(grant));
1351
+ }
1352
+ findBestGrant(userId, deviceId, scope) {
1353
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1354
+ const rows = this.sqlite.prepare(
1355
+ `
1356
+ SELECT * FROM relay_access_grants
1357
+ WHERE target_user_id = ?
1358
+ AND device_id = ?
1359
+ AND revoked_at IS NULL
1360
+ AND (expires_at IS NULL OR expires_at > ?)
1361
+ ORDER BY
1362
+ CASE scope
1363
+ WHEN 'thread' THEN 3
1364
+ WHEN 'workspace' THEN 2
1365
+ WHEN 'device' THEN 1
1366
+ ELSE 0
1367
+ END DESC,
1368
+ CASE thread_access WHEN 'control' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC,
1369
+ CASE workspace_access WHEN 'write' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC,
1370
+ can_create_threads DESC,
1371
+ created_at DESC
1372
+ `
1373
+ ).all(userId, deviceId, now);
1374
+ const grants = rows.map((row) => this.rowToGrant(row)).filter((grant) => Boolean(grant));
1375
+ const matchingGrants = grants.filter((grant) => this.grantMatchesScope(grant, scope));
1376
+ return matchingGrants.length > 0 ? mergeMatchingGrants(matchingGrants, scope) : null;
1377
+ }
1378
+ grantMatchesScope(grant, scope) {
1379
+ if (grant.scope === "thread") {
1380
+ if (!scope.threadId || grant.threadId !== scope.threadId) {
1381
+ return false;
1382
+ }
1383
+ return true;
1384
+ }
1385
+ if (grant.scope === "workspace") {
1386
+ if (!scope.workspaceId || grant.workspaceId !== scope.workspaceId || grant.workspaceAccess === "none") {
1387
+ return false;
1388
+ }
1389
+ return true;
1390
+ }
1391
+ if (grant.scope === "device") {
1392
+ if (scope.workspaceId && grant.workspaceAccess === "none") {
1393
+ return false;
1394
+ }
1395
+ return true;
1396
+ }
1397
+ return false;
1398
+ }
1399
+ accessFromGrant(grant) {
1400
+ return {
1401
+ kind: "shared",
1402
+ share: null,
1403
+ grant,
1404
+ scope: grant.scope,
1405
+ threadAccess: grant.threadAccess,
1406
+ workspaceAccess: grant.workspaceAccess,
1407
+ workspaceId: grant.workspaceId,
1408
+ workspaceScope: grant.workspaceScope,
1409
+ canCreateThreads: grant.canCreateThreads
1410
+ };
1411
+ }
1412
+ grantFromShare(share) {
1413
+ return {
1414
+ id: share.id,
1415
+ ownerUserId: share.ownerUserId,
1416
+ ownerUsername: share.ownerUsername,
1417
+ targetUserId: share.targetUserId,
1418
+ targetUsername: share.targetUsername,
1419
+ deviceId: share.deviceId,
1420
+ deviceName: share.deviceName,
1421
+ scope: "thread",
1422
+ threadId: share.threadId,
1423
+ threadTitle: share.threadTitle,
1424
+ workspaceId: share.workspaceId,
1425
+ workspaceLabel: share.workspaceLabel,
1426
+ workspaceScope: "selected",
1427
+ workspaceIds: share.workspaceId ? [share.workspaceId] : [],
1428
+ label: share.label,
1429
+ threadAccess: share.threadAccess,
1430
+ workspaceAccess: share.workspaceAccess,
1431
+ canCreateThreads: false,
1432
+ createdAt: share.createdAt,
1433
+ revokedAt: share.revokedAt,
1434
+ expiresAt: share.expiresAt,
1435
+ lastAccessedAt: share.lastAccessedAt,
1436
+ lastAccessedByUsername: share.lastAccessedByUsername,
1437
+ accessEvents: share.accessEvents.map((event) => ({
1438
+ id: event.id,
1439
+ grantId: event.shareId,
1440
+ userId: event.userId,
1441
+ username: event.username,
1442
+ kind: event.kind,
1443
+ accessedAt: event.accessedAt
1444
+ }))
1445
+ };
1446
+ }
1034
1447
  conversationCountsByUser(days) {
1035
1448
  const since = new Date(Date.now() - days * 24 * 60 * 60 * 1e3).toISOString();
1036
1449
  const rows = this.sqlite.prepare(
@@ -1129,12 +1542,41 @@ var RelayStore = class _RelayStore {
1129
1542
  deviceId: row.device_id,
1130
1543
  deviceName: row.device_name ?? "Remote Codex device",
1131
1544
  threadId: row.thread_id,
1132
- threadTitle: null,
1545
+ threadTitle: row.thread_title ?? null,
1133
1546
  workspaceId: row.workspace_id ?? null,
1134
- workspaceLabel: null,
1547
+ workspaceLabel: row.workspace_label ?? null,
1548
+ label: row.label,
1549
+ threadAccess: normalizeThreadAccess(row.thread_access),
1550
+ workspaceAccess: normalizeWorkspaceAccess(row.workspace_access),
1551
+ createdAt: row.created_at,
1552
+ revokedAt: row.revoked_at,
1553
+ expiresAt: row.expires_at ?? null,
1554
+ lastAccessedAt: null,
1555
+ lastAccessedByUsername: null,
1556
+ accessEvents: []
1557
+ };
1558
+ }
1559
+ rowToGrant(row) {
1560
+ if (!row) return null;
1561
+ return {
1562
+ id: row.id,
1563
+ ownerUserId: row.owner_user_id,
1564
+ ownerUsername: row.owner_username ?? "unknown",
1565
+ targetUserId: row.target_user_id,
1566
+ targetUsername: row.target_username ?? "unknown",
1567
+ deviceId: row.device_id,
1568
+ deviceName: row.device_name ?? "Remote Codex device",
1569
+ scope: normalizeShareScope(row.scope),
1570
+ threadId: row.thread_id ?? null,
1571
+ threadTitle: row.thread_title ?? null,
1572
+ workspaceId: row.workspace_id ?? null,
1573
+ workspaceLabel: row.workspace_label ?? null,
1574
+ workspaceScope: normalizeWorkspaceScope(row.workspace_scope),
1575
+ workspaceIds: parseWorkspaceIds(row.workspace_ids),
1135
1576
  label: row.label,
1136
1577
  threadAccess: normalizeThreadAccess(row.thread_access),
1137
1578
  workspaceAccess: normalizeWorkspaceAccess(row.workspace_access),
1579
+ canCreateThreads: Boolean(row.can_create_threads),
1138
1580
  createdAt: row.created_at,
1139
1581
  revokedAt: row.revoked_at,
1140
1582
  expiresAt: row.expires_at ?? null,
@@ -1179,6 +1621,100 @@ function normalizeWorkspaceAccess(value) {
1179
1621
  }
1180
1622
  return "none";
1181
1623
  }
1624
+ function normalizeShareScope(value) {
1625
+ if (value === "workspace" || value === "device") {
1626
+ return value;
1627
+ }
1628
+ return "thread";
1629
+ }
1630
+ function normalizeWorkspaceScope(value) {
1631
+ return value === "selected" ? "selected" : "all";
1632
+ }
1633
+ function normalizeWorkspaceIds(values) {
1634
+ if (!Array.isArray(values)) {
1635
+ return [];
1636
+ }
1637
+ return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))).sort();
1638
+ }
1639
+ function mergeMatchingGrants(grants, scope) {
1640
+ const sorted = [...grants].sort(compareGrantCapability);
1641
+ const grantScope = mergedGrantScope(sorted);
1642
+ const representative = sorted.find((grant) => grant.scope === grantScope) ?? sorted[0];
1643
+ const threadAccess = sorted.reduce(
1644
+ (best, grant) => maxThreadAccess(best, grant.threadAccess),
1645
+ "read"
1646
+ );
1647
+ const workspaceAccess = sorted.reduce(
1648
+ (best, grant) => maxWorkspaceAccess(best, grant.workspaceAccess),
1649
+ "none"
1650
+ );
1651
+ return {
1652
+ ...representative,
1653
+ scope: grantScope,
1654
+ threadId: grantScope === "thread" ? scope.threadId ?? representative.threadId : null,
1655
+ workspaceId: grantScope === "workspace" ? scope.workspaceId ?? representative.workspaceId : null,
1656
+ threadAccess,
1657
+ workspaceAccess,
1658
+ canCreateThreads: sorted.some((grant) => grant.canCreateThreads)
1659
+ };
1660
+ }
1661
+ function mergedGrantScope(grants) {
1662
+ if (grants.some((grant) => grant.scope === "device")) {
1663
+ return "device";
1664
+ }
1665
+ if (grants.some((grant) => grant.scope === "workspace")) {
1666
+ return "workspace";
1667
+ }
1668
+ return "thread";
1669
+ }
1670
+ function maxThreadAccess(left, right) {
1671
+ return threadAccessScore(right) > threadAccessScore(left) ? right : left;
1672
+ }
1673
+ function maxWorkspaceAccess(left, right) {
1674
+ return workspaceAccessScore(right) > workspaceAccessScore(left) ? right : left;
1675
+ }
1676
+ function compareGrantCapability(left, right) {
1677
+ return threadAccessScore(right.threadAccess) - threadAccessScore(left.threadAccess) || workspaceAccessScore(right.workspaceAccess) - workspaceAccessScore(left.workspaceAccess) || Number(right.canCreateThreads) - Number(left.canCreateThreads) || grantScopeScore(right.scope) - grantScopeScore(left.scope) || right.createdAt.localeCompare(left.createdAt);
1678
+ }
1679
+ function threadAccessScore(value) {
1680
+ return value === "control" ? 2 : value === "read" ? 1 : 0;
1681
+ }
1682
+ function workspaceAccessScore(value) {
1683
+ return value === "write" ? 2 : value === "read" ? 1 : 0;
1684
+ }
1685
+ function grantScopeScore(value) {
1686
+ if (value === "thread") {
1687
+ return 3;
1688
+ }
1689
+ if (value === "workspace") {
1690
+ return 2;
1691
+ }
1692
+ return 1;
1693
+ }
1694
+ function normalizeAccessEventKind(value) {
1695
+ switch (value) {
1696
+ case "open_device":
1697
+ case "open_thread":
1698
+ case "create_thread":
1699
+ case "send_prompt":
1700
+ case "read_workspace_file":
1701
+ case "write_workspace_file":
1702
+ return value;
1703
+ default:
1704
+ return "access";
1705
+ }
1706
+ }
1707
+ function parseWorkspaceIds(value) {
1708
+ if (!value) {
1709
+ return [];
1710
+ }
1711
+ try {
1712
+ const parsed = JSON.parse(value);
1713
+ return normalizeWorkspaceIds(Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string") : []);
1714
+ } catch {
1715
+ return [];
1716
+ }
1717
+ }
1182
1718
  function normalizeExpiresAt(value) {
1183
1719
  if (!value) {
1184
1720
  return null;
@@ -1186,6 +1722,12 @@ function normalizeExpiresAt(value) {
1186
1722
  const timestamp = Date.parse(value);
1187
1723
  return Number.isNaN(timestamp) ? null : new Date(timestamp).toISOString();
1188
1724
  }
1725
+ function normalizeOptionalMetadata(value) {
1726
+ if (value === void 0) {
1727
+ return void 0;
1728
+ }
1729
+ return value?.trim() || void 0;
1730
+ }
1189
1731
  function normalizeConversationWindowDays(value) {
1190
1732
  if (!Number.isFinite(value ?? NaN)) {
1191
1733
  return 7;
@@ -1220,6 +1762,8 @@ var WEBSOCKET_OPEN = 1;
1220
1762
  var RELAY_COOKIE_NAME = "remote_codex_relay_session";
1221
1763
  var threadAccessSchema = z.enum(["read", "control"]);
1222
1764
  var workspaceAccessSchema = z.enum(["none", "read", "write"]);
1765
+ var grantScopeSchema = z.enum(["thread", "workspace", "device"]);
1766
+ var workspaceScopeSchema = z.enum(["all", "selected"]);
1223
1767
  var loginSchema = z.object({
1224
1768
  identifier: z.string().trim().min(1),
1225
1769
  password: z.string().min(1)
@@ -1254,6 +1798,40 @@ var updateShareSchema = z.object({
1254
1798
  workspaceAccess: workspaceAccessSchema.optional(),
1255
1799
  expiresAt: z.string().datetime().nullable().optional()
1256
1800
  });
1801
+ var createGrantSchema = z.object({
1802
+ targetIdentifier: z.string().trim().min(1).optional(),
1803
+ targetUsername: z.string().trim().min(3).optional(),
1804
+ deviceId: z.string().uuid(),
1805
+ scope: grantScopeSchema,
1806
+ threadId: z.string().trim().min(1).nullable().optional(),
1807
+ workspaceId: z.string().uuid().nullable().optional(),
1808
+ workspaceScope: workspaceScopeSchema.default("all"),
1809
+ workspaceIds: z.array(z.string().uuid()).default([]),
1810
+ label: z.string().trim().min(1).max(160).nullable().optional(),
1811
+ threadAccess: threadAccessSchema.default("control"),
1812
+ workspaceAccess: workspaceAccessSchema.default("none"),
1813
+ canCreateThreads: z.boolean().optional(),
1814
+ expiresAt: z.string().datetime().nullable().optional()
1815
+ }).refine((input) => input.targetIdentifier || input.targetUsername, {
1816
+ message: "targetIdentifier is required.",
1817
+ path: ["targetIdentifier"]
1818
+ }).refine((input) => input.scope !== "thread" || Boolean(input.threadId), {
1819
+ message: "threadId is required for thread grants.",
1820
+ path: ["threadId"]
1821
+ }).refine((input) => input.scope !== "workspace" || Boolean(input.workspaceId), {
1822
+ message: "workspaceId is required for workspace grants.",
1823
+ path: ["workspaceId"]
1824
+ });
1825
+ var updateGrantSchema = z.object({
1826
+ workspaceId: z.string().uuid().nullable().optional(),
1827
+ workspaceScope: workspaceScopeSchema.optional(),
1828
+ workspaceIds: z.array(z.string().uuid()).optional(),
1829
+ label: z.string().trim().min(1).max(160).nullable().optional(),
1830
+ threadAccess: threadAccessSchema.optional(),
1831
+ workspaceAccess: workspaceAccessSchema.optional(),
1832
+ canCreateThreads: z.boolean().optional(),
1833
+ expiresAt: z.string().datetime().nullable().optional()
1834
+ });
1257
1835
  var setEnabledSchema = z.object({
1258
1836
  enabled: z.boolean()
1259
1837
  });
@@ -1461,7 +2039,7 @@ function buildRelayServer(config2, options = {}) {
1461
2039
  if (!user) {
1462
2040
  return;
1463
2041
  }
1464
- return enrichPortalSummary(store.portalSummary(user.id, connectionStatus(state)), state);
2042
+ return enrichPortalSummary(store.portalSummary(user.id, connectionStatus(state)), state, store);
1465
2043
  });
1466
2044
  app2.get("/relay/access", async (request, reply) => {
1467
2045
  const user = requireRelayUser(request, reply, store);
@@ -1533,6 +2111,44 @@ function buildRelayServer(config2, options = {}) {
1533
2111
  const { shareId } = z.object({ shareId: z.string().uuid() }).parse(request.params);
1534
2112
  return store.revokeShare(user.id, shareId);
1535
2113
  });
2114
+ app2.post("/relay/grants", async (request, reply) => {
2115
+ const user = requireRelayUser(request, reply, store);
2116
+ if (!user) {
2117
+ return;
2118
+ }
2119
+ const body = createGrantSchema.parse(request.body ?? {});
2120
+ return store.createGrant(user.id, {
2121
+ targetIdentifier: body.targetIdentifier ?? body.targetUsername,
2122
+ deviceId: body.deviceId,
2123
+ scope: body.scope,
2124
+ threadId: body.threadId ?? null,
2125
+ workspaceId: body.workspaceId ?? null,
2126
+ workspaceScope: body.workspaceScope,
2127
+ workspaceIds: body.workspaceIds,
2128
+ label: body.label ?? null,
2129
+ threadAccess: body.threadAccess,
2130
+ workspaceAccess: body.workspaceAccess,
2131
+ canCreateThreads: body.canCreateThreads ?? false,
2132
+ expiresAt: body.expiresAt ?? null
2133
+ });
2134
+ });
2135
+ app2.patch("/relay/grants/:grantId", async (request, reply) => {
2136
+ const user = requireRelayUser(request, reply, store);
2137
+ if (!user) {
2138
+ return;
2139
+ }
2140
+ const { grantId } = z.object({ grantId: z.string().uuid() }).parse(request.params);
2141
+ const body = updateGrantSchema.parse(request.body ?? {});
2142
+ return store.updateGrant(user.id, grantId, body);
2143
+ });
2144
+ app2.delete("/relay/grants/:grantId", async (request, reply) => {
2145
+ const user = requireRelayUser(request, reply, store);
2146
+ if (!user) {
2147
+ return;
2148
+ }
2149
+ const { grantId } = z.object({ grantId: z.string().uuid() }).parse(request.params);
2150
+ return store.revokeGrant(user.id, grantId);
2151
+ });
1536
2152
  app2.get("/relay/admin", async (request, reply) => {
1537
2153
  const user = requireRelayUser(request, reply, store, { admin: true });
1538
2154
  if (!user) {
@@ -1719,6 +2335,18 @@ function buildRelayServer(config2, options = {}) {
1719
2335
  }
1720
2336
  if (parsed.type === "relay.server.message") {
1721
2337
  const clientConnection = connection.clientSockets.get(parsed.clientId);
2338
+ if (clientConnection) {
2339
+ const eventThreadId = threadIdFromSocketPayload(parsed.payload);
2340
+ const freshAccess = store.effectiveAccess(clientConnection.user.id, clientConnection.deviceId, {
2341
+ threadId: clientConnection.threadId ?? eventThreadId
2342
+ });
2343
+ if (!freshAccess) {
2344
+ connection.clientSockets.delete(parsed.clientId);
2345
+ clientConnection.socket.close(1008, "Shared access is no longer allowed.");
2346
+ return;
2347
+ }
2348
+ clientConnection.access = freshAccess;
2349
+ }
1722
2350
  if (clientConnection && clientConnection.socket.readyState === WEBSOCKET_OPEN && shouldForwardSocketEvent(parsed.payload, clientConnection.threadId)) {
1723
2351
  clientConnection.socket.send(JSON.stringify(parsed.payload));
1724
2352
  }
@@ -1766,9 +2394,9 @@ function buildRelayServer(config2, options = {}) {
1766
2394
  return;
1767
2395
  }
1768
2396
  if (access.kind === "shared") {
1769
- store.recordShareAccess(access.share, session.user);
2397
+ recordRelayAccess(store, access, session.user, threadId ? "open_thread" : "open_device");
1770
2398
  }
1771
- connectRelayWebsocket(supervisor, socket, threadId, access);
2399
+ connectRelayWebsocket(supervisor, socket, store, session.user, deviceId, threadId, access);
1772
2400
  }
1773
2401
  });
1774
2402
  realtimeApp.route({
@@ -1799,9 +2427,9 @@ function buildRelayServer(config2, options = {}) {
1799
2427
  return;
1800
2428
  }
1801
2429
  if (access.kind === "shared") {
1802
- store.recordShareAccess(access.share, session.user);
2430
+ recordRelayAccess(store, access, session.user, threadId ? "open_thread" : "open_device");
1803
2431
  }
1804
- connectRelayWebsocket(supervisor, socket, threadId, access);
2432
+ connectRelayWebsocket(supervisor, socket, store, session.user, deviceId, threadId, access);
1805
2433
  }
1806
2434
  });
1807
2435
  });
@@ -1900,7 +2528,13 @@ async function forwardRelayHttp(input) {
1900
2528
  return;
1901
2529
  }
1902
2530
  if (access?.kind === "shared") {
1903
- input.store.recordShareAccess(access.share, input.user);
2531
+ const accessEventKind = relayAccessEventKindFromRequest(
2532
+ input.request.method,
2533
+ input.targetPath
2534
+ );
2535
+ if (accessEventKind) {
2536
+ recordRelayAccess(input.store, access, input.user, accessEventKind);
2537
+ }
1904
2538
  }
1905
2539
  const conversationEvent = conversationEventFromRequest(
1906
2540
  input.request.method,
@@ -1983,9 +2617,9 @@ function relayResponseBody(response) {
1983
2617
  }
1984
2618
  return response.body;
1985
2619
  }
1986
- function connectRelayWebsocket(supervisor, socket, threadId, access) {
2620
+ function connectRelayWebsocket(supervisor, socket, store, user, deviceId, threadId, access) {
1987
2621
  const clientId = randomUUID();
1988
- supervisor.clientSockets.set(clientId, { socket, threadId, access });
2622
+ supervisor.clientSockets.set(clientId, { socket, threadId, deviceId, user, access });
1989
2623
  sendToSupervisor(supervisor, {
1990
2624
  type: "relay.client.connected",
1991
2625
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1998,7 +2632,19 @@ function connectRelayWebsocket(supervisor, socket, threadId, access) {
1998
2632
  } catch {
1999
2633
  return;
2000
2634
  }
2001
- if (access.kind === "shared" && access.threadAccess !== "control") {
2635
+ const freshAccess = store.effectiveAccess(user.id, deviceId, {
2636
+ threadId: threadId ?? threadIdFromSocketPayload(payload)
2637
+ });
2638
+ if (!freshAccess) {
2639
+ supervisor.clientSockets.delete(clientId);
2640
+ socket.close(1008, "Shared access is no longer allowed.");
2641
+ return;
2642
+ }
2643
+ const clientConnection = supervisor.clientSockets.get(clientId);
2644
+ if (clientConnection) {
2645
+ clientConnection.access = freshAccess;
2646
+ }
2647
+ if (freshAccess.kind === "shared" && freshAccess.threadAccess !== "control") {
2002
2648
  socket.close(1008, "Shared read-only session cannot control supervisor.");
2003
2649
  return;
2004
2650
  }
@@ -2018,6 +2664,9 @@ function connectRelayWebsocket(supervisor, socket, threadId, access) {
2018
2664
  });
2019
2665
  });
2020
2666
  }
2667
+ function threadIdFromSocketPayload(payload) {
2668
+ return isObject(payload) && typeof payload.threadId === "string" ? payload.threadId : null;
2669
+ }
2021
2670
  function sendToSupervisor(supervisor, message) {
2022
2671
  if (supervisor.socket.readyState === WEBSOCKET_OPEN) {
2023
2672
  supervisor.socket.send(JSON.stringify(message));
@@ -2233,13 +2882,16 @@ async function fetchRelayThreads(supervisor, deviceId) {
2233
2882
  }
2234
2883
  return threads.slice(0, 80);
2235
2884
  }
2236
- async function enrichPortalSummary(portal, state) {
2885
+ async function enrichPortalSummary(portal, state, store) {
2237
2886
  const threadCache = /* @__PURE__ */ new Map();
2238
2887
  const workspaceCache = /* @__PURE__ */ new Map();
2239
2888
  const enrichShare = async (share) => {
2240
2889
  const supervisor = state.supervisors.get(share.deviceId);
2241
2890
  if (!supervisor || supervisor.socket.readyState !== WEBSOCKET_OPEN) {
2242
- return share;
2891
+ return {
2892
+ ...share,
2893
+ threadTitle: stableShareThreadTitle(share)
2894
+ };
2243
2895
  }
2244
2896
  const threadCacheKey = `${share.deviceId}:${share.threadId}`;
2245
2897
  let threadTitlePromise = threadCache.get(threadCacheKey);
@@ -2266,10 +2918,16 @@ async function enrichPortalSummary(portal, state) {
2266
2918
  threadTitlePromise,
2267
2919
  workspaceLabelPromise
2268
2920
  ]);
2921
+ if (threadTitle || workspaceLabel) {
2922
+ store.updateShareMetadata(share.id, {
2923
+ threadTitle,
2924
+ workspaceLabel
2925
+ });
2926
+ }
2269
2927
  return {
2270
2928
  ...share,
2271
- threadTitle,
2272
- workspaceLabel
2929
+ threadTitle: threadTitle ?? stableShareThreadTitle(share),
2930
+ workspaceLabel: workspaceLabel ?? share.workspaceLabel
2273
2931
  };
2274
2932
  };
2275
2933
  const [sharedWithMe, sharedByMe] = await Promise.all([
@@ -2295,6 +2953,14 @@ async function fetchRelayWorkspaceLabel(supervisor, deviceId, workspaceId) {
2295
2953
  const payload = await forwardSupervisorJson(supervisor, deviceId, `/api/workspaces/${encodeURIComponent(workspaceId)}`);
2296
2954
  return stringField(payload, "label");
2297
2955
  }
2956
+ function stableShareThreadTitle(share) {
2957
+ const threadTitle = share.threadTitle?.trim();
2958
+ if (!threadTitle) {
2959
+ return null;
2960
+ }
2961
+ const label = share.label?.trim();
2962
+ return label && threadTitle === label ? null : threadTitle;
2963
+ }
2298
2964
  async function forwardSupervisorJson(supervisor, deviceId, targetPath, options = {}) {
2299
2965
  try {
2300
2966
  const response = await supervisor.requestBroker.forward(
@@ -2341,12 +3007,26 @@ function stringField(value, field) {
2341
3007
  function relayAccessDto(access) {
2342
3008
  return {
2343
3009
  kind: access.kind,
3010
+ grantId: access.grant?.id ?? null,
2344
3011
  shareId: access.share?.id ?? null,
3012
+ scope: access.scope,
2345
3013
  threadAccess: access.threadAccess,
2346
3014
  workspaceAccess: access.workspaceAccess,
2347
- workspaceId: access.workspaceId
3015
+ workspaceId: access.workspaceId,
3016
+ workspaceScope: access.workspaceScope,
3017
+ canCreateThreads: access.canCreateThreads
2348
3018
  };
2349
3019
  }
3020
+ function recordRelayAccess(store, access, user, kind) {
3021
+ if (access.kind !== "shared") {
3022
+ return;
3023
+ }
3024
+ if (access.share) {
3025
+ store.recordShareAccess(access.share, user, kind);
3026
+ return;
3027
+ }
3028
+ store.recordGrantAccess(access.grant, user, kind);
3029
+ }
2350
3030
  function firstAccessibleConnectedDevice(state, store, userId, threadId) {
2351
3031
  for (const deviceId of state.supervisors.keys()) {
2352
3032
  if (store.canAccessDevice(userId, deviceId, threadId)) {
@@ -2366,6 +3046,9 @@ function isAllowedSharedRuntimeMetadataRequest(method, pathname) {
2366
3046
  if (pathname === "/api/agent-runtimes") {
2367
3047
  return true;
2368
3048
  }
3049
+ if (pathname === "/api/plugins") {
3050
+ return true;
3051
+ }
2369
3052
  return /^\/api\/agent-runtimes\/[^/]+\/(?:status|models)$/.test(pathname);
2370
3053
  }
2371
3054
  function threadIdFromPath(pathValue) {
@@ -2398,6 +3081,29 @@ function conversationEventFromRequest(method, pathValue, body) {
2398
3081
  }
2399
3082
  return null;
2400
3083
  }
3084
+ function relayAccessEventKindFromRequest(method, pathValue) {
3085
+ const methodName = method.toUpperCase();
3086
+ const pathname = new URL(pathValue, "http://relay.local").pathname;
3087
+ if (methodName === "POST" && pathname === "/api/threads/start") {
3088
+ return "create_thread";
3089
+ }
3090
+ if (methodName === "POST" && /^\/api\/threads\/[^/]+\/prompt$/.test(pathname)) {
3091
+ return "send_prompt";
3092
+ }
3093
+ if (methodName === "GET" && /^\/api\/threads\/[^/]+$/.test(pathname)) {
3094
+ return "open_thread";
3095
+ }
3096
+ if (methodName === "GET" && /^\/api\/workspaces\/[^/]+$/.test(pathname)) {
3097
+ return "open_device";
3098
+ }
3099
+ if (methodName === "GET" && /^\/api\/workspaces\/[^/]+\/(?:files\/(?:tree|preview|raw|download)|artifacts(?:\/[^/]+(?:\/download)?)?)$/.test(pathname)) {
3100
+ return "read_workspace_file";
3101
+ }
3102
+ if (["POST", "PUT", "PATCH", "DELETE"].includes(methodName) && /^\/api\/workspaces\/[^/]+\/files(?:\/(?:upload|move))?$/.test(pathname)) {
3103
+ return "write_workspace_file";
3104
+ }
3105
+ return null;
3106
+ }
2401
3107
  function relayClientIp(request) {
2402
3108
  const forwarded = firstHeaderValue(request.headers["cf-connecting-ip"]) ?? firstHeaderValue(request.headers["x-real-ip"]) ?? firstHeaderValue(request.headers["x-forwarded-for"]);
2403
3109
  if (forwarded) {
@@ -2417,6 +3123,12 @@ function isAllowedForRelayAccess(access, method, pathValue) {
2417
3123
  }
2418
3124
  const pathname = new URL(pathValue, "http://relay.local").pathname;
2419
3125
  const methodName = method.toUpperCase();
3126
+ if (isAllowedSharedRuntimeMetadataRequest(methodName, pathname)) {
3127
+ return true;
3128
+ }
3129
+ if (access.scope === "device" && methodName === "POST" && pathname === "/api/threads/start") {
3130
+ return access.canCreateThreads && access.threadAccess === "control";
3131
+ }
2420
3132
  const threadId = threadIdFromPath(pathValue);
2421
3133
  if (threadId) {
2422
3134
  return isAllowedSharedThreadPath(access, methodName, pathname, threadId);
@@ -2425,10 +3137,18 @@ function isAllowedForRelayAccess(access, method, pathValue) {
2425
3137
  if (workspaceId) {
2426
3138
  return isAllowedSharedWorkspacePath(access, methodName, pathname, workspaceId);
2427
3139
  }
3140
+ if (access.scope === "device") {
3141
+ if (methodName === "GET" && (pathname === "/api/threads" || pathname === "/api/workspaces")) {
3142
+ return true;
3143
+ }
3144
+ }
2428
3145
  return false;
2429
3146
  }
2430
3147
  function isAllowedSharedThreadPath(access, methodName, pathname, threadId) {
2431
- if (access.kind !== "shared" || access.share.threadId !== threadId) {
3148
+ if (access.kind !== "shared") {
3149
+ return false;
3150
+ }
3151
+ if (access.scope !== "device" && access.grant.threadId !== threadId) {
2432
3152
  return false;
2433
3153
  }
2434
3154
  const escapedThreadId = escapeRegExp(encodeURIComponent(threadId));
@@ -2485,7 +3205,10 @@ function isAllowedSharedThreadPath(access, methodName, pathname, threadId) {
2485
3205
  return false;
2486
3206
  }
2487
3207
  function isAllowedSharedWorkspacePath(access, methodName, pathname, workspaceId) {
2488
- if (access.kind !== "shared" || access.workspaceAccess === "none" || access.workspaceId !== workspaceId) {
3208
+ if (access.kind !== "shared" || access.workspaceAccess === "none") {
3209
+ return false;
3210
+ }
3211
+ if (access.scope !== "device" && access.workspaceId !== workspaceId) {
2489
3212
  return false;
2490
3213
  }
2491
3214
  const escapedWorkspaceId = escapeRegExp(encodeURIComponent(workspaceId));