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/CHANGELOG.md +47 -0
- package/README.md +21 -0
- package/dist/cli.js +889 -323
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +26 -12
- package/dist/index.js +100 -27
- package/dist/index.js.map +1 -1
- package/dist/schema.js.map +1 -1
- package/dist/server.d.ts +51 -15
- package/dist/server.js +212 -61
- package/dist/server.js.map +1 -1
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +28 -25
- package/dist/testing.js.map +1 -1
- package/dist/worker.d.ts +69 -10
- package/dist/worker.js +251 -61
- package/dist/worker.js.map +1 -1
- package/features/admin-page/src/AdminPage.tsx +4 -4
- package/features/documentation/feature.json +2 -1
- package/features/documents/feature.json +3 -2
- package/features/documents/src/DocumentsEditorPage.tsx +47 -62
- package/features/documents/src/DocumentsListPage.tsx +59 -26
- package/features/documents/src/InviteDialog.tsx +49 -14
- package/features/documents/src/LibrarySidebar.tsx +92 -84
- package/features/documents/src/__tests__/documents-presence-access.test.ts +15 -2
- package/features/documents/src/documents-library-types.ts +6 -0
- package/features/documents/src/editor/useDocEditor.ts +1 -1
- package/features/documents/src/use-documents-presence-access.ts +1 -3
- package/features/tasks/src/TasksPage.tsx +6 -3
- package/package.json +5 -5
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(
|
|
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
|
|
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.
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1626
|
-
|
|
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
|
-
|
|
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,
|
|
1975
|
-
const
|
|
1976
|
-
const
|
|
1977
|
-
|
|
1978
|
-
|
|
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
|
|
2021
|
-
|
|
2022
|
-
|
|
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
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
4139
|
-
|
|
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,
|