remote-codex 0.11.22 → 0.11.24

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.
@@ -183,7 +183,7 @@ var RelayStore = class _RelayStore {
183
183
  if (!device || device.ownerUserId !== ownerUserId) {
184
184
  throw new RelayStoreError(404, "not_found", "Device was not found.");
185
185
  }
186
- const target = this.getUserByUsername(input.targetUsername);
186
+ const target = this.getUserByIdentifier(input.targetIdentifier);
187
187
  if (!target || !target.enabled) {
188
188
  throw new RelayStoreError(404, "not_found", "Target user was not found.");
189
189
  }
@@ -203,7 +203,13 @@ var RelayStore = class _RelayStore {
203
203
  ).get(ownerUserId, target.id, input.deviceId, input.threadId)
204
204
  );
205
205
  if (existing) {
206
- return existing;
206
+ return this.updateShareRecord(existing.id, {
207
+ label: input.label?.trim() || null,
208
+ workspaceId: input.workspaceId?.trim() || null,
209
+ threadAccess: normalizeThreadAccess(input.threadAccess),
210
+ workspaceAccess: normalizeWorkspaceAccess(input.workspaceAccess),
211
+ expiresAt: normalizeExpiresAt(input.expiresAt)
212
+ });
207
213
  }
208
214
  const share = {
209
215
  id: crypto.randomUUID(),
@@ -214,13 +220,37 @@ var RelayStore = class _RelayStore {
214
220
  deviceId: input.deviceId,
215
221
  deviceName: device.name,
216
222
  threadId: input.threadId,
223
+ workspaceId: input.workspaceId?.trim() || null,
217
224
  label: input.label?.trim() || null,
225
+ threadAccess: normalizeThreadAccess(input.threadAccess),
226
+ workspaceAccess: normalizeWorkspaceAccess(input.workspaceAccess),
218
227
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
219
- revokedAt: null
228
+ revokedAt: null,
229
+ expiresAt: normalizeExpiresAt(input.expiresAt),
230
+ lastAccessedAt: null,
231
+ lastAccessedByUsername: null,
232
+ accessEvents: []
220
233
  };
221
234
  this.insertShare(share);
222
235
  return share;
223
236
  }
237
+ updateShare(userId, shareId, input) {
238
+ const share = this.rowToShare(
239
+ this.sqlite.prepare("SELECT * FROM relay_shares WHERE id = ? AND owner_user_id = ? AND revoked_at IS NULL").get(shareId, userId)
240
+ );
241
+ if (!share) {
242
+ throw new RelayStoreError(404, "not_found", "Share was not found.");
243
+ }
244
+ return this.publicShare(
245
+ this.updateShareRecord(shareId, {
246
+ label: input.label !== void 0 ? input.label?.trim() || null : share.label,
247
+ workspaceId: input.workspaceId !== void 0 ? input.workspaceId?.trim() || null : share.workspaceId,
248
+ threadAccess: input.threadAccess !== void 0 ? normalizeThreadAccess(input.threadAccess) : share.threadAccess,
249
+ workspaceAccess: input.workspaceAccess !== void 0 ? normalizeWorkspaceAccess(input.workspaceAccess) : share.workspaceAccess,
250
+ expiresAt: input.expiresAt !== void 0 ? normalizeExpiresAt(input.expiresAt) : share.expiresAt
251
+ })
252
+ );
253
+ }
224
254
  revokeShare(userId, shareId) {
225
255
  const share = this.rowToShare(
226
256
  this.sqlite.prepare("SELECT * FROM relay_shares WHERE id = ? AND owner_user_id = ?").get(shareId, userId)
@@ -232,24 +262,83 @@ var RelayStore = class _RelayStore {
232
262
  this.sqlite.prepare("UPDATE relay_shares SET revoked_at = ? WHERE id = ?").run(revokedAt, shareId);
233
263
  return { ...share, revokedAt };
234
264
  }
235
- canAccessDevice(userId, deviceId, threadId) {
265
+ effectiveAccess(userId, deviceId, scope = {}) {
236
266
  const owned = this.sqlite.prepare("SELECT 1 FROM relay_devices WHERE id = ? AND owner_user_id = ?").get(deviceId, userId);
237
267
  if (owned) {
238
- return true;
239
- }
240
- if (!threadId) {
241
- return false;
268
+ return {
269
+ kind: "owner",
270
+ share: null,
271
+ threadAccess: "control",
272
+ workspaceAccess: "write",
273
+ workspaceId: null
274
+ };
275
+ }
276
+ const now = (/* @__PURE__ */ new Date()).toISOString();
277
+ if (scope.threadId) {
278
+ const share = this.rowToShare(
279
+ this.sqlite.prepare(
280
+ `
281
+ SELECT * FROM relay_shares
282
+ WHERE target_user_id = ?
283
+ AND device_id = ?
284
+ AND thread_id = ?
285
+ AND revoked_at IS NULL
286
+ AND (expires_at IS NULL OR expires_at > ?)
287
+ ORDER BY created_at DESC
288
+ LIMIT 1
289
+ `
290
+ ).get(userId, deviceId, scope.threadId, now)
291
+ );
292
+ if (!share) {
293
+ return null;
294
+ }
295
+ if (scope.workspaceId && (!share.workspaceId || share.workspaceId !== scope.workspaceId || share.workspaceAccess === "none")) {
296
+ return null;
297
+ }
298
+ return {
299
+ kind: "shared",
300
+ share,
301
+ threadAccess: share.threadAccess,
302
+ workspaceAccess: share.workspaceAccess,
303
+ workspaceId: share.workspaceId
304
+ };
305
+ }
306
+ if (scope.workspaceId) {
307
+ const share = this.rowToShare(
308
+ this.sqlite.prepare(
309
+ `
310
+ SELECT * FROM relay_shares
311
+ WHERE target_user_id = ?
312
+ AND device_id = ?
313
+ AND workspace_id = ?
314
+ AND workspace_access <> 'none'
315
+ AND revoked_at IS NULL
316
+ AND (expires_at IS NULL OR expires_at > ?)
317
+ ORDER BY
318
+ CASE workspace_access WHEN 'write' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC,
319
+ created_at DESC
320
+ LIMIT 1
321
+ `
322
+ ).get(userId, deviceId, scope.workspaceId, now)
323
+ );
324
+ if (!share) {
325
+ return null;
326
+ }
327
+ return {
328
+ kind: "shared",
329
+ share,
330
+ threadAccess: share.threadAccess,
331
+ workspaceAccess: share.workspaceAccess,
332
+ workspaceId: share.workspaceId
333
+ };
242
334
  }
335
+ return null;
336
+ }
337
+ canAccessDevice(userId, deviceId, threadId) {
243
338
  return Boolean(
244
- this.sqlite.prepare(
245
- `
246
- SELECT 1 FROM relay_shares
247
- WHERE target_user_id = ?
248
- AND device_id = ?
249
- AND thread_id = ?
250
- AND revoked_at IS NULL
251
- `
252
- ).get(userId, deviceId, threadId)
339
+ this.effectiveAccess(userId, deviceId, {
340
+ threadId: threadId ?? null
341
+ })
253
342
  );
254
343
  }
255
344
  portalSummary(userId, connectedDevices) {
@@ -335,15 +424,33 @@ var RelayStore = class _RelayStore {
335
424
  createdAt: device.createdAt
336
425
  };
337
426
  }
427
+ recordShareAccess(share, user) {
428
+ if (share.revokedAt || share.expiresAt && share.expiresAt <= (/* @__PURE__ */ new Date()).toISOString()) {
429
+ return;
430
+ }
431
+ const accessedAt = (/* @__PURE__ */ new Date()).toISOString();
432
+ this.sqlite.prepare(
433
+ `
434
+ INSERT INTO relay_share_access_events (
435
+ id, share_id, user_id, username, accessed_at
436
+ ) VALUES (?, ?, ?, ?, ?)
437
+ `
438
+ ).run(crypto.randomUUID(), share.id, user.id, user.username, accessedAt);
439
+ }
338
440
  publicShare(share) {
339
441
  const owner = this.getUser(share.ownerUserId);
340
442
  const target = this.getUser(share.targetUserId);
341
443
  const device = this.getDevice(share.deviceId);
444
+ const accessEvents = this.getShareAccessEvents(share.id);
445
+ const lastAccess = accessEvents[0] ?? null;
342
446
  return {
343
447
  ...share,
344
448
  ownerUsername: share.ownerUsername ?? owner?.username ?? "unknown",
345
449
  targetUsername: share.targetUsername ?? target?.username ?? "unknown",
346
- deviceName: share.deviceName ?? device?.name ?? "Remote Codex device"
450
+ deviceName: share.deviceName ?? device?.name ?? "Remote Codex device",
451
+ lastAccessedAt: lastAccess?.accessedAt ?? null,
452
+ lastAccessedByUsername: lastAccess?.username ?? null,
453
+ accessEvents
347
454
  };
348
455
  }
349
456
  migrate() {
@@ -385,16 +492,34 @@ var RelayStore = class _RelayStore {
385
492
  device_id TEXT NOT NULL REFERENCES relay_devices(id) ON DELETE CASCADE,
386
493
  device_name TEXT,
387
494
  thread_id TEXT NOT NULL,
495
+ workspace_id TEXT,
388
496
  label TEXT,
497
+ thread_access TEXT NOT NULL DEFAULT 'control',
498
+ workspace_access TEXT NOT NULL DEFAULT 'none',
389
499
  created_at TEXT NOT NULL,
390
- revoked_at TEXT
500
+ revoked_at TEXT,
501
+ expires_at TEXT
391
502
  );
392
503
 
393
504
  CREATE INDEX IF NOT EXISTS relay_shares_owner_idx ON relay_shares(owner_user_id);
394
505
  CREATE INDEX IF NOT EXISTS relay_shares_target_idx ON relay_shares(target_user_id);
395
506
  CREATE INDEX IF NOT EXISTS relay_shares_device_thread_idx ON relay_shares(device_id, thread_id);
507
+
508
+ CREATE TABLE IF NOT EXISTS relay_share_access_events (
509
+ id TEXT PRIMARY KEY,
510
+ share_id TEXT NOT NULL REFERENCES relay_shares(id) ON DELETE CASCADE,
511
+ user_id TEXT NOT NULL REFERENCES relay_users(id) ON DELETE CASCADE,
512
+ username TEXT NOT NULL,
513
+ accessed_at TEXT NOT NULL
514
+ );
515
+
516
+ CREATE INDEX IF NOT EXISTS relay_share_access_events_share_idx ON relay_share_access_events(share_id, accessed_at DESC);
396
517
  `);
397
518
  this.ensureColumn("relay_devices", "token", "TEXT");
519
+ this.ensureColumn("relay_shares", "workspace_id", "TEXT");
520
+ this.ensureColumn("relay_shares", "thread_access", "TEXT NOT NULL DEFAULT 'control'");
521
+ this.ensureColumn("relay_shares", "workspace_access", "TEXT NOT NULL DEFAULT 'none'");
522
+ this.ensureColumn("relay_shares", "expires_at", "TEXT");
398
523
  }
399
524
  ensureColumn(table, column, definition) {
400
525
  const columns = this.sqlite.prepare(`PRAGMA table_info(${table})`).all();
@@ -573,8 +698,9 @@ var RelayStore = class _RelayStore {
573
698
  `
574
699
  INSERT INTO relay_shares (
575
700
  id, owner_user_id, owner_username, target_user_id, target_username,
576
- device_id, device_name, thread_id, label, created_at, revoked_at
577
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
701
+ device_id, device_name, thread_id, workspace_id, label,
702
+ thread_access, workspace_access, created_at, revoked_at, expires_at
703
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
578
704
  `
579
705
  ).run(
580
706
  share.id,
@@ -585,9 +711,52 @@ var RelayStore = class _RelayStore {
585
711
  share.deviceId,
586
712
  share.deviceName,
587
713
  share.threadId,
714
+ share.workspaceId,
588
715
  share.label,
716
+ share.threadAccess,
717
+ share.workspaceAccess,
589
718
  share.createdAt,
590
- share.revokedAt
719
+ share.revokedAt,
720
+ share.expiresAt
721
+ );
722
+ }
723
+ getShareAccessEvents(shareId) {
724
+ return this.sqlite.prepare(
725
+ `
726
+ SELECT * FROM relay_share_access_events
727
+ WHERE share_id = ?
728
+ ORDER BY accessed_at DESC
729
+ LIMIT 8
730
+ `
731
+ ).all(shareId).map((row) => ({
732
+ id: row.id,
733
+ shareId: row.share_id,
734
+ userId: row.user_id,
735
+ username: row.username,
736
+ accessedAt: row.accessed_at
737
+ }));
738
+ }
739
+ updateShareRecord(shareId, input) {
740
+ this.sqlite.prepare(
741
+ `
742
+ UPDATE relay_shares
743
+ SET label = ?,
744
+ workspace_id = ?,
745
+ thread_access = ?,
746
+ workspace_access = ?,
747
+ expires_at = ?
748
+ WHERE id = ?
749
+ `
750
+ ).run(
751
+ input.label,
752
+ input.workspaceId,
753
+ input.threadAccess,
754
+ input.workspaceAccess,
755
+ input.expiresAt,
756
+ shareId
757
+ );
758
+ return this.rowToShare(
759
+ this.sqlite.prepare("SELECT * FROM relay_shares WHERE id = ?").get(shareId)
591
760
  );
592
761
  }
593
762
  getUser(id) {
@@ -616,10 +785,10 @@ var RelayStore = class _RelayStore {
616
785
  return this.sqlite.prepare("SELECT * FROM relay_devices WHERE owner_user_id = ? ORDER BY created_at ASC").all(ownerUserId).map((row) => this.rowToDevice(row)).filter((device) => Boolean(device));
617
786
  }
618
787
  getSharesByOwner(ownerUserId) {
619
- return this.sqlite.prepare("SELECT * FROM relay_shares WHERE owner_user_id = ? AND revoked_at IS NULL ORDER BY created_at ASC").all(ownerUserId).map((row) => this.rowToShare(row)).filter((share) => Boolean(share));
788
+ return this.sqlite.prepare("SELECT * FROM relay_shares 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.rowToShare(row)).filter((share) => Boolean(share));
620
789
  }
621
790
  getSharesByTarget(targetUserId) {
622
- return this.sqlite.prepare("SELECT * FROM relay_shares WHERE target_user_id = ? AND revoked_at IS NULL ORDER BY created_at ASC").all(targetUserId).map((row) => this.rowToShare(row)).filter((share) => Boolean(share));
791
+ 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));
623
792
  }
624
793
  rowToUser(row) {
625
794
  if (!row) return null;
@@ -657,9 +826,16 @@ var RelayStore = class _RelayStore {
657
826
  deviceId: row.device_id,
658
827
  deviceName: row.device_name ?? "Remote Codex device",
659
828
  threadId: row.thread_id,
829
+ workspaceId: row.workspace_id ?? null,
660
830
  label: row.label,
831
+ threadAccess: normalizeThreadAccess(row.thread_access),
832
+ workspaceAccess: normalizeWorkspaceAccess(row.workspace_access),
661
833
  createdAt: row.created_at,
662
- revokedAt: row.revoked_at
834
+ revokedAt: row.revoked_at,
835
+ expiresAt: row.expires_at ?? null,
836
+ lastAccessedAt: null,
837
+ lastAccessedByUsername: null,
838
+ accessEvents: []
663
839
  };
664
840
  }
665
841
  };
@@ -675,6 +851,22 @@ var RelayStoreError = class extends Error {
675
851
  function normalizeUsername(value) {
676
852
  return value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "");
677
853
  }
854
+ function normalizeThreadAccess(value) {
855
+ return value === "read" ? "read" : "control";
856
+ }
857
+ function normalizeWorkspaceAccess(value) {
858
+ if (value === "read" || value === "write") {
859
+ return value;
860
+ }
861
+ return "none";
862
+ }
863
+ function normalizeExpiresAt(value) {
864
+ if (!value) {
865
+ return null;
866
+ }
867
+ const timestamp = Date.parse(value);
868
+ return Number.isNaN(timestamp) ? null : new Date(timestamp).toISOString();
869
+ }
678
870
  function hashSecret(secret, salt) {
679
871
  return crypto.scryptSync(secret, salt, 32).toString("base64url");
680
872
  }
@@ -700,7 +892,8 @@ function safeEqual(left, right) {
700
892
  var RELAY_REQUEST_TIMEOUT_MS = 3e4;
701
893
  var WEBSOCKET_OPEN = 1;
702
894
  var RELAY_COOKIE_NAME = "remote_codex_relay_session";
703
- var THREAD_SHARED_HTTP_METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PATCH"]);
895
+ var threadAccessSchema = z.enum(["read", "control"]);
896
+ var workspaceAccessSchema = z.enum(["none", "read", "write"]);
704
897
  var loginSchema = z.object({
705
898
  identifier: z.string().trim().min(1),
706
899
  password: z.string().min(1)
@@ -715,10 +908,25 @@ var createDeviceSchema = z.object({
715
908
  name: z.string().trim().min(1).max(120)
716
909
  });
717
910
  var createShareSchema = z.object({
718
- targetUsername: z.string().trim().min(3),
911
+ targetIdentifier: z.string().trim().min(1).optional(),
912
+ targetUsername: z.string().trim().min(3).optional(),
719
913
  deviceId: z.string().uuid(),
720
914
  threadId: z.string().trim().min(1),
721
- label: z.string().trim().min(1).max(160).optional()
915
+ workspaceId: z.string().uuid().nullable().optional(),
916
+ label: z.string().trim().min(1).max(160).nullable().optional(),
917
+ threadAccess: threadAccessSchema.default("control"),
918
+ workspaceAccess: workspaceAccessSchema.default("none"),
919
+ expiresAt: z.string().datetime().nullable().optional()
920
+ }).refine((input) => input.targetIdentifier || input.targetUsername, {
921
+ message: "targetIdentifier is required.",
922
+ path: ["targetIdentifier"]
923
+ });
924
+ var updateShareSchema = z.object({
925
+ workspaceId: z.string().uuid().nullable().optional(),
926
+ label: z.string().trim().min(1).max(160).nullable().optional(),
927
+ threadAccess: threadAccessSchema.optional(),
928
+ workspaceAccess: workspaceAccessSchema.optional(),
929
+ expiresAt: z.string().datetime().nullable().optional()
722
930
  });
723
931
  var setEnabledSchema = z.object({
724
932
  enabled: z.boolean()
@@ -730,6 +938,11 @@ var updatePasswordSchema = z.object({
730
938
  currentPassword: z.string().min(1),
731
939
  newPassword: z.string().min(8)
732
940
  });
941
+ var relayAccessQuerySchema = z.object({
942
+ deviceId: z.string().uuid(),
943
+ threadId: z.string().trim().min(1).optional(),
944
+ workspaceId: z.string().uuid().optional()
945
+ });
733
946
  var DEFAULT_WEBVIEW_CORS_ORIGINS = /* @__PURE__ */ new Set([
734
947
  "null",
735
948
  "capacitor://localhost",
@@ -749,6 +962,58 @@ var WEBVIEW_CORS_ALLOW_METHODS = [
749
962
  "DELETE",
750
963
  "OPTIONS"
751
964
  ].join(", ");
965
+ var RELAY_REQUEST_HEADER_BLOCKLIST = /* @__PURE__ */ new Set([
966
+ "authorization",
967
+ "connection",
968
+ "content-length",
969
+ "cookie",
970
+ "expect",
971
+ "forwarded",
972
+ "host",
973
+ "keep-alive",
974
+ "origin",
975
+ "proxy-authenticate",
976
+ "proxy-authorization",
977
+ "proxy-connection",
978
+ "referer",
979
+ "referrer",
980
+ "set-cookie",
981
+ "te",
982
+ "trailer",
983
+ "transfer-encoding",
984
+ "upgrade",
985
+ "via",
986
+ "x-client-ip",
987
+ "x-forwarded-for",
988
+ "x-forwarded-host",
989
+ "x-forwarded-port",
990
+ "x-forwarded-proto",
991
+ "x-forwarded-protocol",
992
+ "x-forwarded-scheme",
993
+ "x-real-ip",
994
+ "x-remote-codex-relay-forwarded"
995
+ ]);
996
+ var RELAY_RESPONSE_HEADER_BLOCKLIST = /* @__PURE__ */ new Set([
997
+ "access-control-allow-credentials",
998
+ "access-control-allow-headers",
999
+ "access-control-allow-methods",
1000
+ "access-control-allow-origin",
1001
+ "access-control-expose-headers",
1002
+ "access-control-max-age",
1003
+ "access-control-request-headers",
1004
+ "access-control-request-method",
1005
+ "connection",
1006
+ "content-length",
1007
+ "keep-alive",
1008
+ "location",
1009
+ "proxy-authenticate",
1010
+ "refresh",
1011
+ "set-cookie",
1012
+ "te",
1013
+ "trailer",
1014
+ "transfer-encoding",
1015
+ "upgrade"
1016
+ ]);
752
1017
  function buildRelayServer(config2, options = {}) {
753
1018
  const app2 = Fastify({ logger: false });
754
1019
  app2.addContentTypeParser("*", { parseAs: "buffer" }, (_request, body, done) => {
@@ -846,6 +1111,25 @@ function buildRelayServer(config2, options = {}) {
846
1111
  }
847
1112
  return store.portalSummary(user.id, connectionStatus(state));
848
1113
  });
1114
+ app2.get("/relay/access", async (request, reply) => {
1115
+ const user = requireRelayUser(request, reply, store);
1116
+ if (!user) {
1117
+ return;
1118
+ }
1119
+ const query = relayAccessQuerySchema.parse(request.query ?? {});
1120
+ const access = store.effectiveAccess(user.id, query.deviceId, {
1121
+ threadId: query.threadId ?? null,
1122
+ workspaceId: query.workspaceId ?? null
1123
+ });
1124
+ if (!access) {
1125
+ reply.status(403).send({
1126
+ code: "forbidden",
1127
+ message: "Device access is not allowed."
1128
+ });
1129
+ return;
1130
+ }
1131
+ return relayAccessDto(access);
1132
+ });
849
1133
  app2.post("/relay/devices", async (request, reply) => {
850
1134
  const user = requireRelayUser(request, reply, store);
851
1135
  if (!user) {
@@ -870,12 +1154,25 @@ function buildRelayServer(config2, options = {}) {
870
1154
  }
871
1155
  const body = createShareSchema.parse(request.body ?? {});
872
1156
  return store.createShare(user.id, {
873
- targetUsername: body.targetUsername,
1157
+ targetIdentifier: body.targetIdentifier ?? body.targetUsername,
874
1158
  deviceId: body.deviceId,
875
1159
  threadId: body.threadId,
876
- label: body.label ?? null
1160
+ workspaceId: body.workspaceId ?? null,
1161
+ label: body.label ?? null,
1162
+ threadAccess: body.threadAccess,
1163
+ workspaceAccess: body.workspaceAccess,
1164
+ expiresAt: body.expiresAt ?? null
877
1165
  });
878
1166
  });
1167
+ app2.patch("/relay/shares/:shareId", async (request, reply) => {
1168
+ const user = requireRelayUser(request, reply, store);
1169
+ if (!user) {
1170
+ return;
1171
+ }
1172
+ const { shareId } = z.object({ shareId: z.string().uuid() }).parse(request.params);
1173
+ const body = updateShareSchema.parse(request.body ?? {});
1174
+ return store.updateShare(user.id, shareId, body);
1175
+ });
879
1176
  app2.delete("/relay/shares/:shareId", async (request, reply) => {
880
1177
  const user = requireRelayUser(request, reply, store);
881
1178
  if (!user) {
@@ -1059,7 +1356,8 @@ function buildRelayServer(config2, options = {}) {
1059
1356
  socket.close(1008, "Relay login is required.");
1060
1357
  return;
1061
1358
  }
1062
- if (!store.canAccessDevice(session.user.id, deviceId, threadId)) {
1359
+ const access = store.effectiveAccess(session.user.id, deviceId, { threadId });
1360
+ if (!access) {
1063
1361
  socket.close(1008, "Device access is not allowed.");
1064
1362
  return;
1065
1363
  }
@@ -1068,7 +1366,10 @@ function buildRelayServer(config2, options = {}) {
1068
1366
  socket.close(1013, "No supervisor is connected for this device.");
1069
1367
  return;
1070
1368
  }
1071
- connectRelayWebsocket(supervisor, socket, threadId);
1369
+ if (access.kind === "shared") {
1370
+ store.recordShareAccess(access.share, session.user);
1371
+ }
1372
+ connectRelayWebsocket(supervisor, socket, threadId, access);
1072
1373
  }
1073
1374
  });
1074
1375
  realtimeApp.route({
@@ -1089,11 +1390,19 @@ function buildRelayServer(config2, options = {}) {
1089
1390
  const threadId = queryString(request.query, "threadId");
1090
1391
  const deviceId = firstAccessibleConnectedDevice(state, store, session.user.id, threadId);
1091
1392
  const supervisor = deviceId ? state.supervisors.get(deviceId) : null;
1393
+ const access = deviceId ? store.effectiveAccess(session.user.id, deviceId, { threadId }) : null;
1092
1394
  if (!deviceId || !supervisor || supervisor.socket.readyState !== WEBSOCKET_OPEN) {
1093
1395
  socket.close(1013, "No accessible supervisor is connected to this relay.");
1094
1396
  return;
1095
1397
  }
1096
- connectRelayWebsocket(supervisor, socket, threadId);
1398
+ if (!access) {
1399
+ socket.close(1008, "Device access is not allowed.");
1400
+ return;
1401
+ }
1402
+ if (access.kind === "shared") {
1403
+ store.recordShareAccess(access.share, session.user);
1404
+ }
1405
+ connectRelayWebsocket(supervisor, socket, threadId, access);
1097
1406
  }
1098
1407
  });
1099
1408
  });
@@ -1141,20 +1450,35 @@ function applyWebViewCorsHeaders(reply, origin) {
1141
1450
  }
1142
1451
  async function forwardRelayHttp(input) {
1143
1452
  const threadId = threadIdFromPath(input.targetPath);
1144
- if (!input.store.canAccessDevice(input.user.id, input.deviceId, threadId)) {
1453
+ const workspaceId = workspaceIdFromPath(input.targetPath);
1454
+ const access = input.store.effectiveAccess(input.user.id, input.deviceId, {
1455
+ threadId,
1456
+ workspaceId
1457
+ });
1458
+ if (!access) {
1145
1459
  input.reply.status(403).send({
1146
1460
  code: "forbidden",
1147
1461
  message: "Device access is not allowed."
1148
1462
  });
1149
1463
  return;
1150
1464
  }
1151
- if (!isAllowedForRelayUser(input.store, input.user.id, input.deviceId, input.request.method, input.targetPath)) {
1465
+ if (!isAllowedRelayTarget(input.targetPath)) {
1466
+ input.reply.status(403).send({
1467
+ code: "forbidden",
1468
+ message: "This relay path is not allowed."
1469
+ });
1470
+ return;
1471
+ }
1472
+ if (!isAllowedForRelayAccess(access, input.request.method, input.targetPath)) {
1152
1473
  input.reply.status(403).send({
1153
1474
  code: "forbidden",
1154
1475
  message: "This shared session does not allow that operation."
1155
1476
  });
1156
1477
  return;
1157
1478
  }
1479
+ if (access.kind === "shared") {
1480
+ input.store.recordShareAccess(access.share, input.user);
1481
+ }
1158
1482
  const supervisor = input.state.supervisors.get(input.deviceId);
1159
1483
  if (!supervisor || supervisor.socket.readyState !== WEBSOCKET_OPEN) {
1160
1484
  input.reply.status(503).send({
@@ -1163,13 +1487,6 @@ async function forwardRelayHttp(input) {
1163
1487
  });
1164
1488
  return;
1165
1489
  }
1166
- if (!isAllowedRelayTarget(input.targetPath)) {
1167
- input.reply.status(403).send({
1168
- code: "forbidden",
1169
- message: "This relay path is not allowed."
1170
- });
1171
- return;
1172
- }
1173
1490
  try {
1174
1491
  const requestId = randomUUID();
1175
1492
  const requestBody = relayRequestBody(input.request.body);
@@ -1206,9 +1523,9 @@ function relayResponseBody(response) {
1206
1523
  }
1207
1524
  return response.body;
1208
1525
  }
1209
- function connectRelayWebsocket(supervisor, socket, threadId) {
1526
+ function connectRelayWebsocket(supervisor, socket, threadId, access) {
1210
1527
  const clientId = randomUUID();
1211
- supervisor.clientSockets.set(clientId, { socket, threadId });
1528
+ supervisor.clientSockets.set(clientId, { socket, threadId, access });
1212
1529
  sendToSupervisor(supervisor, {
1213
1530
  type: "relay.client.connected",
1214
1531
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1221,6 +1538,10 @@ function connectRelayWebsocket(supervisor, socket, threadId) {
1221
1538
  } catch {
1222
1539
  return;
1223
1540
  }
1541
+ if (access.kind === "shared" && access.threadAccess !== "control") {
1542
+ socket.close(1008, "Shared read-only session cannot control supervisor.");
1543
+ return;
1544
+ }
1224
1545
  sendToSupervisor(supervisor, {
1225
1546
  type: "relay.client.message",
1226
1547
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1361,6 +1682,15 @@ function connectionStatus(state) {
1361
1682
  }
1362
1683
  return statuses;
1363
1684
  }
1685
+ function relayAccessDto(access) {
1686
+ return {
1687
+ kind: access.kind,
1688
+ shareId: access.share?.id ?? null,
1689
+ threadAccess: access.threadAccess,
1690
+ workspaceAccess: access.workspaceAccess,
1691
+ workspaceId: access.workspaceId
1692
+ };
1693
+ }
1364
1694
  function firstAccessibleConnectedDevice(state, store, userId, threadId) {
1365
1695
  for (const deviceId of state.supervisors.keys()) {
1366
1696
  if (store.canAccessDevice(userId, deviceId, threadId)) {
@@ -1378,33 +1708,91 @@ function threadIdFromPath(pathValue) {
1378
1708
  const match = /^\/api\/threads\/([^/?#]+)/.exec(pathname);
1379
1709
  return match ? decodeURIComponent(match[1]) : null;
1380
1710
  }
1381
- function isAllowedForRelayUser(store, userId, deviceId, method, pathValue) {
1382
- if (store.canAccessDevice(userId, deviceId, null)) {
1711
+ function workspaceIdFromPath(pathValue) {
1712
+ const pathname = new URL(pathValue, "http://relay.local").pathname;
1713
+ const match = /^\/api\/workspaces\/([^/?#]+)/.exec(pathname);
1714
+ return match ? decodeURIComponent(match[1]) : null;
1715
+ }
1716
+ function isAllowedForRelayAccess(access, method, pathValue) {
1717
+ if (access.kind === "owner") {
1383
1718
  return true;
1384
1719
  }
1385
1720
  const pathname = new URL(pathValue, "http://relay.local").pathname;
1721
+ const methodName = method.toUpperCase();
1386
1722
  const threadId = threadIdFromPath(pathValue);
1387
- if (!threadId || !store.canAccessDevice(userId, deviceId, threadId)) {
1388
- return false;
1723
+ if (threadId) {
1724
+ return isAllowedSharedThreadPath(access, methodName, pathname, threadId);
1725
+ }
1726
+ const workspaceId = workspaceIdFromPath(pathValue);
1727
+ if (workspaceId) {
1728
+ return isAllowedSharedWorkspacePath(access, methodName, pathname, workspaceId);
1389
1729
  }
1390
- if (!THREAD_SHARED_HTTP_METHODS.has(method.toUpperCase())) {
1730
+ return false;
1731
+ }
1732
+ function isAllowedSharedThreadPath(access, methodName, pathname, threadId) {
1733
+ if (access.kind !== "shared" || access.share.threadId !== threadId) {
1391
1734
  return false;
1392
1735
  }
1393
1736
  const escapedThreadId = escapeRegExp(encodeURIComponent(threadId));
1394
- const allowed = [
1737
+ const readPatterns = [
1395
1738
  new RegExp(`^/api/threads/${escapedThreadId}$`),
1396
1739
  new RegExp(`^/api/threads/${escapedThreadId}/items/[^/]+/detail$`),
1397
1740
  new RegExp(`^/api/threads/${escapedThreadId}/export-turns$`),
1741
+ new RegExp(`^/api/threads/${escapedThreadId}/exports/pdf$`),
1742
+ new RegExp(`^/api/threads/${escapedThreadId}/assets/image$`),
1398
1743
  new RegExp(`^/api/threads/${escapedThreadId}/goal$`),
1399
1744
  new RegExp(`^/api/threads/${escapedThreadId}/skills$`),
1400
1745
  new RegExp(`^/api/threads/${escapedThreadId}/mcp-servers$`),
1401
- new RegExp(`^/api/threads/${escapedThreadId}/hooks$`),
1746
+ new RegExp(`^/api/threads/${escapedThreadId}/hooks$`)
1747
+ ];
1748
+ if (methodName === "GET" && readPatterns.some((pattern) => pattern.test(pathname))) {
1749
+ return true;
1750
+ }
1751
+ if (access.threadAccess !== "control") {
1752
+ return false;
1753
+ }
1754
+ const controlPatterns = [
1755
+ new RegExp(`^/api/threads/${escapedThreadId}/goal$`),
1402
1756
  new RegExp(`^/api/threads/${escapedThreadId}/resume$`),
1403
1757
  new RegExp(`^/api/threads/${escapedThreadId}/prompt$`),
1404
1758
  new RegExp(`^/api/threads/${escapedThreadId}/interrupt$`),
1405
1759
  new RegExp(`^/api/threads/${escapedThreadId}/requests/[^/]+/respond$`)
1406
1760
  ];
1407
- return allowed.some((pattern) => pattern.test(pathname));
1761
+ if (methodName === "PATCH") {
1762
+ return new RegExp(`^/api/threads/${escapedThreadId}/goal$`).test(pathname);
1763
+ }
1764
+ if (methodName === "POST") {
1765
+ return controlPatterns.some((pattern) => pattern.test(pathname));
1766
+ }
1767
+ return false;
1768
+ }
1769
+ function isAllowedSharedWorkspacePath(access, methodName, pathname, workspaceId) {
1770
+ if (access.kind !== "shared" || access.workspaceAccess === "none" || access.workspaceId !== workspaceId) {
1771
+ return false;
1772
+ }
1773
+ const escapedWorkspaceId = escapeRegExp(encodeURIComponent(workspaceId));
1774
+ const readPatterns = [
1775
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}$`),
1776
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/tree$`),
1777
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/preview$`),
1778
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/raw$`),
1779
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/download$`),
1780
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/artifacts$`),
1781
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/artifacts/[^/]+$`),
1782
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/artifacts/[^/]+/download$`)
1783
+ ];
1784
+ if (methodName === "GET" && readPatterns.some((pattern) => pattern.test(pathname))) {
1785
+ return true;
1786
+ }
1787
+ if (access.workspaceAccess !== "write") {
1788
+ return false;
1789
+ }
1790
+ const writePatterns = [
1791
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files$`),
1792
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/upload$`),
1793
+ new RegExp(`^/api/workspaces/${escapedWorkspaceId}/files/move$`)
1794
+ ];
1795
+ return ["POST", "PUT", "PATCH", "DELETE"].includes(methodName) && writePatterns.some((pattern) => pattern.test(pathname));
1408
1796
  }
1409
1797
  function shouldForwardSocketEvent(event, threadId) {
1410
1798
  if (!threadId) {
@@ -1434,7 +1822,7 @@ function relayRequestHeaders(headers) {
1434
1822
  const output = {};
1435
1823
  for (const [name, value] of Object.entries(headers)) {
1436
1824
  const lower = name.toLowerCase();
1437
- if (lower === "authorization" || lower === "content-length" || lower === "transfer-encoding") {
1825
+ if (RELAY_REQUEST_HEADER_BLOCKLIST.has(lower) || lower.startsWith("x-forwarded-")) {
1438
1826
  continue;
1439
1827
  }
1440
1828
  if (Array.isArray(value)) {
@@ -1447,7 +1835,7 @@ function relayRequestHeaders(headers) {
1447
1835
  }
1448
1836
  function canForwardResponseHeader(name) {
1449
1837
  const lower = name.toLowerCase();
1450
- return lower !== "content-length" && lower !== "transfer-encoding";
1838
+ return !RELAY_RESPONSE_HEADER_BLOCKLIST.has(lower);
1451
1839
  }
1452
1840
  function bearerToken(value) {
1453
1841
  const match = /^Bearer\s+(.+)$/i.exec(value ?? "");