deepspace 0.18.0 → 0.19.1

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/dist/worker.js CHANGED
@@ -1,4 +1,63 @@
1
+ // src/shared/room-identity-headers.ts
2
+ var BASE64URL = /^[A-Za-z0-9_-]*$/;
3
+ var IDENTITY_QUERY_PARAMS = ["userId", "userName", "userEmail", "userImageUrl", "role"];
4
+ var IDENTITY_HEADERS = [
5
+ "x-user-id",
6
+ "x-user-name",
7
+ "x-user-email",
8
+ "x-user-image-url",
9
+ "x-user-role"
10
+ ];
11
+ function encodeRoomIdentityHeader(value) {
12
+ const bytes = new TextEncoder().encode(value);
13
+ let binary = "";
14
+ for (const byte of bytes) binary += String.fromCharCode(byte);
15
+ return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "");
16
+ }
17
+ function decodeRoomIdentityHeader(value) {
18
+ if (value === null || !BASE64URL.test(value) || value.length % 4 === 1) return void 0;
19
+ const base64 = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(value.length + (4 - value.length % 4) % 4, "=");
20
+ try {
21
+ const binary = atob(base64);
22
+ const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
23
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
24
+ } catch {
25
+ return void 0;
26
+ }
27
+ }
28
+ function authenticatedRoomRequest(request, auth, identity) {
29
+ const url = new URL(request.url);
30
+ url.searchParams.delete("token");
31
+ for (const key of IDENTITY_QUERY_PARAMS) url.searchParams.delete(key);
32
+ const forwarded = new Request(url.toString(), request);
33
+ for (const header of IDENTITY_HEADERS) forwarded.headers.delete(header);
34
+ if (!auth) return forwarded;
35
+ forwarded.headers.set("x-user-id", encodeRoomIdentityHeader(auth.userId));
36
+ if (auth.claims.name) {
37
+ forwarded.headers.set("x-user-name", encodeRoomIdentityHeader(auth.claims.name));
38
+ }
39
+ if (auth.claims.email) {
40
+ forwarded.headers.set("x-user-email", encodeRoomIdentityHeader(auth.claims.email));
41
+ }
42
+ if (auth.claims.image) {
43
+ forwarded.headers.set("x-user-image-url", encodeRoomIdentityHeader(auth.claims.image));
44
+ }
45
+ if (identity?.role) {
46
+ forwarded.headers.set("x-user-role", encodeRoomIdentityHeader(identity.role));
47
+ }
48
+ return forwarded;
49
+ }
50
+
1
51
  // src/server/rooms/base-room.ts
52
+ function connectionAttachmentFromRequest(request) {
53
+ return {
54
+ userId: decodeRoomIdentityHeader(request.headers.get("x-user-id")) || `anon-${crypto.randomUUID()}`,
55
+ userName: decodeRoomIdentityHeader(request.headers.get("x-user-name")) || "Anonymous",
56
+ userEmail: decodeRoomIdentityHeader(request.headers.get("x-user-email")) || "",
57
+ userImageUrl: decodeRoomIdentityHeader(request.headers.get("x-user-image-url")),
58
+ role: decodeRoomIdentityHeader(request.headers.get("x-user-role"))
59
+ };
60
+ }
2
61
  var BaseRoom = class {
3
62
  state;
4
63
  env;
@@ -7,9 +66,7 @@ var BaseRoom = class {
7
66
  this.state = state;
8
67
  this.env = env ?? {};
9
68
  this.sql = state.storage.sql;
10
- this.state.setWebSocketAutoResponse(
11
- new WebSocketRequestResponsePair("ping", "pong")
12
- );
69
+ this.state.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
13
70
  }
14
71
  // ==========================================================================
15
72
  // HTTP Entry Point
@@ -59,22 +116,11 @@ var BaseRoom = class {
59
116
  // ==========================================================================
60
117
  // WebSocket Connection
61
118
  // ==========================================================================
62
- async handleWebSocketUpgrade(_request, url) {
63
- const userId = url.searchParams.get("userId") || void 0;
64
- const userName = url.searchParams.get("userName") || "Anonymous";
65
- const userEmail = url.searchParams.get("userEmail") || "";
66
- const userImageUrl = url.searchParams.get("userImageUrl") || void 0;
119
+ async handleWebSocketUpgrade(request, _url) {
67
120
  const pair = new WebSocketPair();
68
121
  const [client, server] = Object.values(pair);
69
122
  this.state.acceptWebSocket(server);
70
- const role = url.searchParams.get("role") || void 0;
71
- const attachment = {
72
- userId: userId ?? `anon-${crypto.randomUUID()}`,
73
- userName,
74
- userEmail,
75
- userImageUrl,
76
- role
77
- };
123
+ const attachment = connectionAttachmentFromRequest(request);
78
124
  const augmented = await this.onConnect(server, attachment) ?? attachment;
79
125
  server.serializeAttachment(augmented);
80
126
  return new Response(null, { status: 101, webSocket: client });
@@ -374,7 +420,7 @@ function dataToColumnValues(data, columns) {
374
420
  const values = {};
375
421
  for (const col of columns) {
376
422
  if (col.expression || col.readonly) continue;
377
- const val = data[col.id] ?? data[col.name];
423
+ const val = data[col.name];
378
424
  if (val !== void 0) {
379
425
  values[col.id] = coerceValue(val, col.storage, col.interpretation);
380
426
  }
@@ -523,6 +569,15 @@ function checkFieldPermissions(schema, role, newData, existingData) {
523
569
  }
524
570
  return null;
525
571
  }
572
+ function checkUnclaimedOwnerTransition(schema, role, nextData, userId) {
573
+ const permissions = getRolePermissions(schema, role);
574
+ if (permissions.update !== "unclaimed-or-own" || !schema.ownerField) return null;
575
+ const nextOwner = nextData[schema.ownerField];
576
+ if (nextOwner === void 0 || nextOwner === null || nextOwner === "" || nextOwner === userId) {
577
+ return null;
578
+ }
579
+ return `Role '${role}' can set '${schema.ownerField}' only to their own user id or leave it unclaimed`;
580
+ }
526
581
  var SYSTEM_MANAGED_COLUMNS = /* @__PURE__ */ new Set([
527
582
  "email",
528
583
  "name",
@@ -566,7 +621,7 @@ function lintSchema(schema) {
566
621
  if (schema.ownerField) {
567
622
  const col = schema.columns.find((c) => c.name === schema.ownerField);
568
623
  const spoofableRoles = Object.entries(schema.permissions).filter(
569
- ([role, permissions]) => role !== "owner" && role !== "admin" && permissions.create === true
624
+ ([role, permissions]) => role !== "owner" && role !== "admin" && permissions.create === true && permissions.update !== "unclaimed-or-own"
570
625
  ).map(([role]) => role);
571
626
  if (col && !col.userBound && spoofableRoles.length > 0) {
572
627
  warnings.push(
@@ -1193,6 +1248,14 @@ function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRb
1193
1248
  return { success: false, error: `Schema not registered for collection: ${collection}` };
1194
1249
  }
1195
1250
  const columns = getResolvedColumns(schema);
1251
+ for (const col of columns) {
1252
+ if (col.id !== col.name && Object.hasOwn(data, col.id)) {
1253
+ return {
1254
+ success: false,
1255
+ error: `FIELD ERROR: physical column id '${col.id}' is not a writable field; use '${col.name}'`
1256
+ };
1257
+ }
1258
+ }
1196
1259
  const existing = getRecord(ctx.sql, collection, recordId, schema);
1197
1260
  const isUpdate = existing !== null;
1198
1261
  const mergedData = isUpdate ? { ...existing.data, ...data } : { ...data };
@@ -1255,10 +1318,6 @@ function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRb
1255
1318
  error: `UPDATE DENIED: role=${userRole}, collection=${collection}`
1256
1319
  };
1257
1320
  }
1258
- const fieldError = checkFieldPermissions(schema, userRole, finalData, existing.data);
1259
- if (fieldError) {
1260
- return { success: false, error: `FIELD ERROR: ${fieldError}` };
1261
- }
1262
1321
  } else {
1263
1322
  if (!canCreate(schema, userRole)) {
1264
1323
  return {
@@ -1267,6 +1326,19 @@ function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRb
1267
1326
  };
1268
1327
  }
1269
1328
  }
1329
+ const fieldError = checkFieldPermissions(
1330
+ schema,
1331
+ userRole,
1332
+ data,
1333
+ isUpdate ? existing.data : void 0
1334
+ );
1335
+ if (fieldError) {
1336
+ return { success: false, error: `FIELD ERROR: ${fieldError}` };
1337
+ }
1338
+ const ownerError = checkUnclaimedOwnerTransition(schema, userRole, finalData, userId);
1339
+ if (ownerError) {
1340
+ return { success: false, error: `FIELD ERROR: ${ownerError}` };
1341
+ }
1270
1342
  }
1271
1343
  const colValues = dataToColumnValues(finalData, columns);
1272
1344
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1519,11 +1591,17 @@ function handleYjsJoin(ctx, ws, attachment, payload) {
1519
1591
  }
1520
1592
  function handleYjsLeave(ctx, ws, attachment, payload) {
1521
1593
  const { collection, recordId, fieldName } = payload;
1594
+ const docKey = getYjsDocKey(collection, recordId, fieldName);
1595
+ const clientId = attachment.awarenessClientIds?.[docKey];
1596
+ if (clientId !== void 0) {
1597
+ broadcastAwarenessClientRemoval(ctx, docKey, clientId);
1598
+ delete attachment.awarenessClientIds[docKey];
1599
+ }
1522
1600
  attachment.yjsSubscriptions = attachment.yjsSubscriptions.filter(
1523
1601
  (s) => !(s.collection === collection && s.recordId === recordId && s.fieldName === fieldName)
1524
1602
  );
1525
1603
  ws.serializeAttachment(attachment);
1526
- unloadYjsDocIfUnused(ctx, getYjsDocKey(collection, recordId, fieldName), ws);
1604
+ unloadYjsDocIfUnused(ctx, docKey, ws);
1527
1605
  }
1528
1606
  function handleYjsBinaryMessage(ctx, ws, attachment, data) {
1529
1607
  const decoder = createDecoder(data);
@@ -1535,12 +1613,13 @@ function handleYjsBinaryMessage(ctx, ws, attachment, data) {
1535
1613
  const parsed2 = parseYjsDocKey(docKey2);
1536
1614
  if (!parsed2) return;
1537
1615
  if (!hasYjsSubscription(attachment, parsed2)) return;
1538
- if (attachment.awarenessClientId == null) {
1616
+ if (attachment.awarenessClientIds?.[docKey2] === void 0) {
1539
1617
  try {
1540
1618
  const pd = createDecoder(payload);
1541
1619
  const count = readVarUint(pd);
1542
1620
  if (count > 0) {
1543
- attachment.awarenessClientId = readVarUint(pd);
1621
+ attachment.awarenessClientIds ??= {};
1622
+ attachment.awarenessClientIds[docKey2] = readVarUint(pd);
1544
1623
  ws.serializeAttachment(attachment);
1545
1624
  }
1546
1625
  } catch {
@@ -1622,18 +1701,21 @@ function broadcastAwareness(ctx, docKey, payload, excludeWs) {
1622
1701
  }
1623
1702
  }
1624
1703
  function broadcastAwarenessRemoval(ctx, attachment) {
1625
- const clientId = attachment.awarenessClientId;
1626
- if (clientId == null || attachment.yjsSubscriptions.length === 0) return;
1704
+ if (!attachment.awarenessClientIds) return;
1705
+ for (const sub of attachment.yjsSubscriptions) {
1706
+ const docKey = getYjsDocKey(sub.collection, sub.recordId, sub.fieldName);
1707
+ const clientId = attachment.awarenessClientIds[docKey];
1708
+ if (clientId !== void 0) broadcastAwarenessClientRemoval(ctx, docKey, clientId);
1709
+ }
1710
+ }
1711
+ function broadcastAwarenessClientRemoval(ctx, docKey, clientId) {
1627
1712
  const payloadEncoder = createEncoder();
1628
1713
  writeVarUint(payloadEncoder, 1);
1629
1714
  writeVarUint(payloadEncoder, clientId);
1630
1715
  writeVarUint(payloadEncoder, 16777215);
1631
1716
  writeVarUint8Array(payloadEncoder, new TextEncoder().encode("null"));
1632
1717
  const removalPayload = toUint8Array(payloadEncoder);
1633
- for (const sub of attachment.yjsSubscriptions) {
1634
- const docKey = getYjsDocKey(sub.collection, sub.recordId, sub.fieldName);
1635
- broadcastAwareness(ctx, docKey, removalPayload, null);
1636
- }
1718
+ broadcastAwareness(ctx, docKey, removalPayload, null);
1637
1719
  }
1638
1720
  function handleYjsDisconnect(ctx, ws, attachment) {
1639
1721
  broadcastAwarenessRemoval(ctx, attachment);
@@ -1910,6 +1992,7 @@ function getAllUserRecords(sql, schemaRegistry) {
1910
1992
  const r = row;
1911
1993
  return {
1912
1994
  recordId: r._row_id,
1995
+ createdBy: r._created_by,
1913
1996
  data: columns.length > 0 ? rowToUserRecord(r, columns) : {
1914
1997
  email: r.col_email || "",
1915
1998
  name: r.col_name || "",
@@ -1971,12 +2054,28 @@ async function registerUser(sql, userId, name, email, imageUrl, isAdmin, default
1971
2054
  );
1972
2055
  return { id: userId, email, name, imageUrl, role, createdAt: now, lastSeenAt: now };
1973
2056
  }
1974
- function handleUserList(ctx, ws, _attachment) {
1975
- const userRecords = getAllUserRecords(ctx.sql, ctx.schemaRegistry);
1976
- const users = userRecords.map((r) => ({
1977
- id: r.recordId,
1978
- ...r.data
1979
- }));
2057
+ function handleUserList(ctx, ws, attachment) {
2058
+ const schema = ctx.schemaRegistry.get("users");
2059
+ const permissionContext = ctx.getPermissionContext();
2060
+ const visibleRecords = attachment.userId.startsWith("anon-") || !schema ? [] : getAllUserRecords(ctx.sql, ctx.schemaRegistry).filter(
2061
+ (record) => canRead(
2062
+ schema,
2063
+ attachment.role,
2064
+ record,
2065
+ attachment.userId,
2066
+ permissionContext
2067
+ )
2068
+ );
2069
+ const users = visibleRecords.map((record) => {
2070
+ if (attachment.role === "admin") return { id: record.recordId, ...record.data };
2071
+ const { name, imageUrl, role } = record.data;
2072
+ return {
2073
+ id: record.recordId,
2074
+ name,
2075
+ ...imageUrl ? { imageUrl } : {},
2076
+ role
2077
+ };
2078
+ });
1980
2079
  ctx.send(ws, { type: MSG.USER_LIST, payload: { users } });
1981
2080
  }
1982
2081
  function handleUserUpdate(ctx, _ws, attachment, payload) {
@@ -2005,24 +2104,18 @@ async function handleSetRole(ctx, ws, attachment, payload) {
2005
2104
  now,
2006
2105
  payload.userId
2007
2106
  );
2008
- if (attachment.userId === payload.userId) {
2009
- attachment.role = payload.role;
2010
- ws.serializeAttachment(attachment);
2011
- }
2012
- const userRecords = getAllUserRecords(ctx.sql, ctx.schemaRegistry);
2013
- const users = userRecords.map((r) => ({
2014
- id: r.recordId,
2015
- ...r.data
2016
- }));
2017
2107
  for (const otherWs of ctx.state.getWebSockets()) {
2018
2108
  const otherAttachment = otherWs.deserializeAttachment();
2019
2109
  if (!otherAttachment) continue;
2020
- if (otherAttachment.userId === payload.userId && otherWs !== ws) {
2021
- otherAttachment.role = payload.role;
2022
- otherWs.serializeAttachment(otherAttachment);
2110
+ if (otherAttachment.userId === payload.userId) {
2111
+ try {
2112
+ otherWs.close(1008, "role-changed");
2113
+ } catch {
2114
+ }
2115
+ continue;
2023
2116
  }
2024
2117
  if (otherAttachment.role === "admin") {
2025
- ctx.send(otherWs, { type: MSG.USER_LIST, payload: { users } });
2118
+ handleUserList(ctx, otherWs, otherAttachment);
2026
2119
  }
2027
2120
  }
2028
2121
  }
@@ -3001,6 +3094,7 @@ var RecordRoom = class extends BaseRoom {
3001
3094
  sql: this.sql,
3002
3095
  state: this.state,
3003
3096
  schemaRegistry: this.schemaRegistry,
3097
+ getPermissionContext: () => this.getPermissionContext(),
3004
3098
  send: (ws, msg) => this.send(ws, msg)
3005
3099
  };
3006
3100
  }
@@ -3551,6 +3645,13 @@ var CanvasRoom = class extends BaseRoom {
3551
3645
  };
3552
3646
 
3553
3647
  // src/server/rooms/presence-room.ts
3648
+ var MAX_PRESENCE_STATE_BYTES = 16 * 1024;
3649
+ function isPlainObject(value) {
3650
+ return value !== null && typeof value === "object" && !Array.isArray(value);
3651
+ }
3652
+ function stateSize(state) {
3653
+ return new TextEncoder().encode(JSON.stringify(state)).byteLength;
3654
+ }
3554
3655
  var PresenceRoom = class extends BaseRoom {
3555
3656
  peers = /* @__PURE__ */ new Map();
3556
3657
  peerSockets = /* @__PURE__ */ new Map();
@@ -3572,8 +3673,6 @@ var PresenceRoom = class extends BaseRoom {
3572
3673
  const recovered = {
3573
3674
  userId: att.userId,
3574
3675
  userName: att.userName,
3575
- userEmail: att.userEmail,
3576
- userImageUrl: att.userImageUrl,
3577
3676
  joinedAt,
3578
3677
  state: {}
3579
3678
  };
@@ -3590,8 +3689,6 @@ var PresenceRoom = class extends BaseRoom {
3590
3689
  const peer = {
3591
3690
  userId: user.userId,
3592
3691
  userName: user.userName,
3593
- userEmail: user.userEmail,
3594
- userImageUrl: user.userImageUrl,
3595
3692
  joinedAt: now,
3596
3693
  state: {}
3597
3694
  };
@@ -3619,15 +3716,28 @@ var PresenceRoom = class extends BaseRoom {
3619
3716
  peer = {
3620
3717
  userId: user.userId,
3621
3718
  userName: user.userName,
3622
- userEmail: user.userEmail,
3623
- userImageUrl: user.userImageUrl,
3624
3719
  joinedAt,
3625
3720
  state: {}
3626
3721
  };
3627
3722
  this.peers.set(user.userId, peer);
3628
3723
  this.peerSockets.set(user.userId, ws);
3629
3724
  }
3630
- peer.state = { ...peer.state, ...payload };
3725
+ if (!isPlainObject(payload)) {
3726
+ this.sendTo(ws, {
3727
+ type: MSG.ERROR,
3728
+ payload: { error: "Presence state must be an object" }
3729
+ });
3730
+ return;
3731
+ }
3732
+ const nextState = { ...peer.state, ...payload };
3733
+ if (stateSize(nextState) > MAX_PRESENCE_STATE_BYTES) {
3734
+ this.sendTo(ws, {
3735
+ type: MSG.ERROR,
3736
+ payload: { error: "Presence state exceeds 16 KiB" }
3737
+ });
3738
+ return;
3739
+ }
3740
+ peer.state = nextState;
3631
3741
  this.peers.set(user.userId, peer);
3632
3742
  this.broadcast(
3633
3743
  {
@@ -3639,7 +3749,10 @@ var PresenceRoom = class extends BaseRoom {
3639
3749
  break;
3640
3750
  }
3641
3751
  default:
3642
- this.sendTo(ws, { type: MSG.ERROR, payload: { error: `Unknown presence message type: ${type}` } });
3752
+ this.sendTo(ws, {
3753
+ type: MSG.ERROR,
3754
+ payload: { error: `Unknown presence message type: ${type}` }
3755
+ });
3643
3756
  }
3644
3757
  }
3645
3758
  onDisconnect(ws, user) {
@@ -4083,6 +4196,9 @@ var JobRoom = class extends BaseRoom {
4083
4196
  retentionMs;
4084
4197
  snapshotLimit;
4085
4198
  retryBackoffMs;
4199
+ authorizeWrite;
4200
+ authorizeRead;
4201
+ broadcastQueue = Promise.resolve();
4086
4202
  /** In-flight jobs in this isolate; lets same-isolate JOB_CANCEL fire the signal. */
4087
4203
  inFlight = /* @__PURE__ */ new Map();
4088
4204
  /** Set by `ctx.continue` inside `executeJob`; reset before every run. */
@@ -4093,6 +4209,8 @@ var JobRoom = class extends BaseRoom {
4093
4209
  this.retentionMs = Math.max(0, config.retentionMs ?? DEFAULT_RETENTION_MS);
4094
4210
  this.snapshotLimit = Math.max(1, config.snapshotLimit ?? DEFAULT_SNAPSHOT_LIMIT);
4095
4211
  this.retryBackoffMs = Math.max(0, config.retryBackoffMs ?? 1e3);
4212
+ this.authorizeWrite = config.authorizeWrite ?? ((user) => user.role === ROLES.MEMBER || user.role === ROLES.ADMIN);
4213
+ this.authorizeRead = config.authorizeRead ?? this.authorizeWrite;
4096
4214
  }
4097
4215
  ensureInitialized() {
4098
4216
  if (this.initialized) return;
@@ -4133,14 +4251,31 @@ var JobRoom = class extends BaseRoom {
4133
4251
  this.ensureInitialized();
4134
4252
  return super.fetch(request);
4135
4253
  }
4136
- onConnect(ws, user) {
4254
+ async onConnect(ws, user) {
4137
4255
  this.ensureInitialized();
4138
- this.sendTo(ws, serverBuild.jobSnapshot(this.getRecentJobs(this.snapshotLimit)));
4139
- return user;
4256
+ const { canRead: canRead2, canWrite } = await this.resolveAccess(user);
4257
+ this.sendTo(ws, { type: MSG.AUTH, payload: { canWrite } });
4258
+ this.sendTo(ws, serverBuild.jobSnapshot(canRead2 ? this.getRecentJobs(this.snapshotLimit) : []));
4259
+ return { ...user, canWrite };
4140
4260
  }
4141
4261
  async onMessage(ws, user, message) {
4142
4262
  this.ensureInitialized();
4143
4263
  const { type, payload } = message;
4264
+ if (type === MSG.JOB_ENQUEUE || type === MSG.JOB_CANCEL || type === MSG.JOB_RETRY) {
4265
+ const attachment = user;
4266
+ const { canWrite } = await this.resolveAccess(user);
4267
+ if (attachment.canWrite !== canWrite) {
4268
+ attachment.canWrite = canWrite;
4269
+ ws.serializeAttachment(attachment);
4270
+ this.sendTo(ws, { type: MSG.AUTH, payload: { canWrite } });
4271
+ } else if (!canWrite) {
4272
+ this.sendTo(ws, { type: MSG.AUTH, payload: { canWrite: false } });
4273
+ }
4274
+ if (!canWrite) {
4275
+ this.sendTo(ws, serverBuild.error("Write access denied: job queue requires member access"));
4276
+ return;
4277
+ }
4278
+ }
4144
4279
  switch (type) {
4145
4280
  case MSG.JOB_ENQUEUE: {
4146
4281
  const requestId = String(payload.requestId ?? "");
@@ -4483,6 +4618,29 @@ var JobRoom = class extends BaseRoom {
4483
4618
  const row = this.sql.exec(`SELECT status FROM jobs WHERE id = ?`, jobId).toArray()[0];
4484
4619
  return row?.status ?? null;
4485
4620
  }
4621
+ /** Reauthorize every live event so role revocation does not leave a stale reader. */
4622
+ broadcast(message, exclude) {
4623
+ this.broadcastQueue = this.broadcastQueue.then(async () => {
4624
+ await Promise.all(
4625
+ this.getWebSockets().map(async (ws) => {
4626
+ if (ws === exclude) return;
4627
+ try {
4628
+ const user = this.getAttachment(ws);
4629
+ if (!user) return;
4630
+ if (await this.authorizeRead(user)) this.sendTo(ws, message);
4631
+ } catch {
4632
+ }
4633
+ })
4634
+ );
4635
+ });
4636
+ this.state.waitUntil(this.broadcastQueue);
4637
+ }
4638
+ async resolveAccess(user) {
4639
+ const canRead2 = await this.authorizeRead(user);
4640
+ if (!canRead2) return { canRead: false, canWrite: false };
4641
+ const canWrite = this.authorizeRead === this.authorizeWrite || await this.authorizeWrite(user);
4642
+ return { canRead: true, canWrite };
4643
+ }
4486
4644
  // ==========================================================================
4487
4645
  // Scheduling / recovery / pruning
4488
4646
  // ==========================================================================
@@ -5606,6 +5764,7 @@ var RESERVED_COLLECTION_NAMES = new Set(
5606
5764
  var APP_MIGRATION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/;
5607
5765
  var MAX_APP_MIGRATION_IDS = 256;
5608
5766
  var WORKER_OWNED_NOT_FOUND_MIGRATION_ID = "2026-08-worker-owned-not-found";
5767
+ var SECURE_ROOM_BOUNDARIES_MIGRATION_ID = "2026-08-secure-room-boundaries";
5609
5768
  function validateAppMigrationIds(value) {
5610
5769
  if (!Array.isArray(value)) {
5611
5770
  return { valid: false, reason: "app migrations must be a JSON string array" };
@@ -6638,6 +6797,33 @@ function buildCronContext(env, ownerUserId, roomId = "default") {
6638
6797
  return { records, integrations, ownerUserId };
6639
6798
  }
6640
6799
 
6800
+ // src/server/utils/app-role.ts
6801
+ async function resolveAppRole(env, userId) {
6802
+ if (userId === env.OWNER_USER_ID) return "admin";
6803
+ const stub = env.RECORD_ROOMS.get(env.RECORD_ROOMS.idFromName(`app:${env.DEEPSPACE_APP_ID}`));
6804
+ try {
6805
+ const res = await stub.fetch(
6806
+ new Request("https://internal/api/tools/execute", {
6807
+ method: "POST",
6808
+ headers: {
6809
+ "Content-Type": "application/json",
6810
+ "X-User-Id": env.OWNER_USER_ID,
6811
+ "X-App-Action": "true"
6812
+ },
6813
+ body: JSON.stringify({
6814
+ tool: "records.get",
6815
+ params: { collection: "users", recordId: userId }
6816
+ })
6817
+ })
6818
+ );
6819
+ const json = await res.json();
6820
+ const role = json.data?.record?.data?.role;
6821
+ return json.success && (role === "admin" || role === "member") ? role : "viewer";
6822
+ } catch {
6823
+ return "viewer";
6824
+ }
6825
+ }
6826
+
6641
6827
  // src/server/utils/ai.ts
6642
6828
  import { createAnthropic } from "@ai-sdk/anthropic";
6643
6829
  import { createOpenAI } from "@ai-sdk/openai";
@@ -9137,6 +9323,7 @@ export {
9137
9323
  RecordRoom,
9138
9324
  RegistryClientError,
9139
9325
  SDK_RUN_WORKER_FIRST,
9326
+ SECURE_ROOM_BOUNDARIES_MIGRATION_ID,
9140
9327
  STRICT_APP_ID_RE,
9141
9328
  SYSTEM_COLLECTIONS,
9142
9329
  SYSTEM_MANAGED_COLUMNS,
@@ -9155,6 +9342,7 @@ export {
9155
9342
  applyAiToolDefaults,
9156
9343
  applySlidingWindow,
9157
9344
  authWorkerFetch,
9345
+ authenticatedRoomRequest,
9158
9346
  bindingManifestFromOutputConfig,
9159
9347
  buildCronContext,
9160
9348
  buildTableSelect,
@@ -9167,6 +9355,7 @@ export {
9167
9355
  capToolResultSize,
9168
9356
  captureScreenshot,
9169
9357
  checkFieldPermissions,
9358
+ checkUnclaimedOwnerTransition,
9170
9359
  clientBuild,
9171
9360
  coerceValue,
9172
9361
  collectionTableName,
@@ -9231,6 +9420,7 @@ export {
9231
9420
  registryErrorJson,
9232
9421
  registryStub,
9233
9422
  resolveAppName,
9423
+ resolveAppRole,
9234
9424
  resolveColumn,
9235
9425
  resolveCompatibilityFlags,
9236
9426
  resolveDeepSpaceAgentModel,