deepspace 0.23.0 → 0.23.2

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,3 +1,83 @@
1
+ // src/shared/protocol/constants.ts
2
+ var MSG = {
3
+ // ---- Records / CRUD --------------------------------------------------
4
+ SUBSCRIBE: "core.subscribe",
5
+ UNSUBSCRIBE: "core.unsubscribe",
6
+ QUERY_RESULT: "core.query_result",
7
+ RECORD_CHANGE: "core.record_change",
8
+ PUT: "core.put",
9
+ DELETE: "core.delete",
10
+ ERROR: "core.error",
11
+ // ---- Users -----------------------------------------------------------
12
+ USER_INFO: "user.info",
13
+ USER_LIST: "user.list",
14
+ SET_ROLE: "user.set_role",
15
+ USER_UPDATE: "user.update",
16
+ // ---- Auth signal -----------------------------------------------------
17
+ // Server → client one-shot on connect from rooms that enforce role-based
18
+ // writes (Canvas, Game, Cron). Tells the client whether this connection
19
+ // can write so its hook can disable local write APIs and the UI can
20
+ // render controls accordingly. Without this, a viewer's click would
21
+ // round-trip to the server and silently come back as ERROR.
22
+ AUTH: "auth",
23
+ // ---- Yjs JSON control frames (binary sync lives in ./yjs.ts) ---------
24
+ YJS_JOIN: "yjs.join",
25
+ YJS_LEAVE: "yjs.leave",
26
+ // ---- Records room misc -----------------------------------------------
27
+ ACK: "records.ack",
28
+ LIST_SCHEMAS: "records.list_schemas",
29
+ RESUBSCRIBE: "records.resubscribe",
30
+ // ---- CanvasRoom ------------------------------------------------------
31
+ CANVAS_SHAPES: "canvas.shapes",
32
+ CANVAS_ADD: "canvas.add",
33
+ CANVAS_MOVE: "canvas.move",
34
+ CANVAS_RESIZE: "canvas.resize",
35
+ CANVAS_DELETE: "canvas.delete",
36
+ CANVAS_UPDATE: "canvas.update",
37
+ CANVAS_VIEWPORT: "canvas.viewport",
38
+ CANVAS_UNDO: "canvas.undo",
39
+ CANVAS_REDO: "canvas.redo",
40
+ // ---- CronRoom --------------------------------------------------------
41
+ CRON_TASKS: "cron.tasks",
42
+ CRON_HISTORY: "cron.history",
43
+ CRON_TRIGGER: "cron.trigger",
44
+ CRON_PAUSE: "cron.pause",
45
+ CRON_RESUME: "cron.resume",
46
+ CRON_STATUS: "cron.status",
47
+ // Per-mutation receipt, sent only to the socket whose trigger/pause/
48
+ // resume carried a `requestId` — frames without one stay fire-and-forget.
49
+ CRON_ACK: "cron.ack",
50
+ // ---- JobRoom ---------------------------------------------------------
51
+ // Background-job queue with durable, alarm-driven execution. See
52
+ // server/rooms/job-room.ts. Clients enqueue and subscribe to updates;
53
+ // the room broadcasts JOB_UPDATE for every status transition.
54
+ JOB_ENQUEUE: "job.enqueue",
55
+ JOB_CANCEL: "job.cancel",
56
+ JOB_RETRY: "job.retry",
57
+ JOB_UPDATE: "job.update",
58
+ // ---- PresenceRoom ----------------------------------------------------
59
+ PRESENCE_SYNC: "presence.sync",
60
+ PRESENCE_JOIN: "presence.join",
61
+ PRESENCE_LEAVE: "presence.leave",
62
+ PRESENCE_UPDATE: "presence.update",
63
+ // ---- Gateway multiplexing --------------------------------------------
64
+ GW_SCOPE_CONNECT: "gateway.scope_connect",
65
+ GW_SCOPE_DISCONNECT: "gateway.scope_disconnect",
66
+ GW_SCOPE_ERROR: "gateway.scope_error",
67
+ GW_TOKEN_REFRESH: "gateway.token_refresh",
68
+ GW_USER_UPDATE: "gateway.user_update"
69
+ };
70
+ var MSG_YJS_SYNC = 22;
71
+ var MSG_YJS_AWARENESS = 23;
72
+ var ROLE_ANONYMOUS = "viewer";
73
+ var ANONYMOUS_USER_ID_PREFIX = "anon-";
74
+ function isAnonymousUserId(userId) {
75
+ return userId.startsWith(ANONYMOUS_USER_ID_PREFIX);
76
+ }
77
+ var ROLE_DEFAULT = "member";
78
+ var ROLE_ADMIN = "admin";
79
+ var RECORD_NOT_FOUND = "Record not found";
80
+
1
81
  // src/shared/room-identity-headers.ts
2
82
  var BASE64URL = /^[A-Za-z0-9_-]*$/;
3
83
  var IDENTITY_QUERY_PARAMS = ["userId", "userName", "userEmail", "userImageUrl", "role"];
@@ -51,7 +131,7 @@ function authenticatedRoomRequest(request, auth, identity) {
51
131
  // src/server/rooms/base-room.ts
52
132
  function connectionAttachmentFromRequest(request) {
53
133
  return {
54
- userId: decodeRoomIdentityHeader(request.headers.get("x-user-id")) || `anon-${crypto.randomUUID()}`,
134
+ userId: decodeRoomIdentityHeader(request.headers.get("x-user-id")) || `${ANONYMOUS_USER_ID_PREFIX}${crypto.randomUUID()}`,
55
135
  userName: decodeRoomIdentityHeader(request.headers.get("x-user-name")) || "Anonymous",
56
136
  userEmail: decodeRoomIdentityHeader(request.headers.get("x-user-email")) || "",
57
137
  userImageUrl: decodeRoomIdentityHeader(request.headers.get("x-user-image-url")),
@@ -305,82 +385,6 @@ var BaseRoom = class {
305
385
  }
306
386
  };
307
387
 
308
- // src/shared/protocol/constants.ts
309
- var MSG = {
310
- // ---- Records / CRUD --------------------------------------------------
311
- SUBSCRIBE: "core.subscribe",
312
- UNSUBSCRIBE: "core.unsubscribe",
313
- QUERY_RESULT: "core.query_result",
314
- RECORD_CHANGE: "core.record_change",
315
- PUT: "core.put",
316
- DELETE: "core.delete",
317
- ERROR: "core.error",
318
- // ---- Users -----------------------------------------------------------
319
- USER_INFO: "user.info",
320
- USER_LIST: "user.list",
321
- SET_ROLE: "user.set_role",
322
- USER_UPDATE: "user.update",
323
- // ---- Auth signal -----------------------------------------------------
324
- // Server → client one-shot on connect from rooms that enforce role-based
325
- // writes (Canvas, Game, Cron). Tells the client whether this connection
326
- // can write so its hook can disable local write APIs and the UI can
327
- // render controls accordingly. Without this, a viewer's click would
328
- // round-trip to the server and silently come back as ERROR.
329
- AUTH: "auth",
330
- // ---- Yjs JSON control frames (binary sync lives in ./yjs.ts) ---------
331
- YJS_JOIN: "yjs.join",
332
- YJS_LEAVE: "yjs.leave",
333
- // ---- Records room misc -----------------------------------------------
334
- ACK: "records.ack",
335
- LIST_SCHEMAS: "records.list_schemas",
336
- RESUBSCRIBE: "records.resubscribe",
337
- // ---- CanvasRoom ------------------------------------------------------
338
- CANVAS_SHAPES: "canvas.shapes",
339
- CANVAS_ADD: "canvas.add",
340
- CANVAS_MOVE: "canvas.move",
341
- CANVAS_RESIZE: "canvas.resize",
342
- CANVAS_DELETE: "canvas.delete",
343
- CANVAS_UPDATE: "canvas.update",
344
- CANVAS_VIEWPORT: "canvas.viewport",
345
- CANVAS_UNDO: "canvas.undo",
346
- CANVAS_REDO: "canvas.redo",
347
- // ---- CronRoom --------------------------------------------------------
348
- CRON_TASKS: "cron.tasks",
349
- CRON_HISTORY: "cron.history",
350
- CRON_TRIGGER: "cron.trigger",
351
- CRON_PAUSE: "cron.pause",
352
- CRON_RESUME: "cron.resume",
353
- CRON_STATUS: "cron.status",
354
- // Per-mutation receipt, sent only to the socket whose trigger/pause/
355
- // resume carried a `requestId` — frames without one stay fire-and-forget.
356
- CRON_ACK: "cron.ack",
357
- // ---- JobRoom ---------------------------------------------------------
358
- // Background-job queue with durable, alarm-driven execution. See
359
- // server/rooms/job-room.ts. Clients enqueue and subscribe to updates;
360
- // the room broadcasts JOB_UPDATE for every status transition.
361
- JOB_ENQUEUE: "job.enqueue",
362
- JOB_CANCEL: "job.cancel",
363
- JOB_RETRY: "job.retry",
364
- JOB_UPDATE: "job.update",
365
- // ---- PresenceRoom ----------------------------------------------------
366
- PRESENCE_SYNC: "presence.sync",
367
- PRESENCE_JOIN: "presence.join",
368
- PRESENCE_LEAVE: "presence.leave",
369
- PRESENCE_UPDATE: "presence.update",
370
- // ---- Gateway multiplexing --------------------------------------------
371
- GW_SCOPE_CONNECT: "gateway.scope_connect",
372
- GW_SCOPE_DISCONNECT: "gateway.scope_disconnect",
373
- GW_SCOPE_ERROR: "gateway.scope_error",
374
- GW_TOKEN_REFRESH: "gateway.token_refresh",
375
- GW_USER_UPDATE: "gateway.user_update"
376
- };
377
- var MSG_YJS_SYNC = 22;
378
- var MSG_YJS_AWARENESS = 23;
379
- var ROLE_ANONYMOUS = "viewer";
380
- var ROLE_DEFAULT = "member";
381
- var ROLE_ADMIN = "admin";
382
- var RECORD_NOT_FOUND = "Record not found";
383
-
384
388
  // src/server/schemas/registry.ts
385
389
  function collectionTableName(name) {
386
390
  return `c_${name.replace(/[^a-zA-Z0-9_]/g, "_")}`;
@@ -640,12 +644,28 @@ function lintSchema(schema) {
640
644
  }
641
645
  return warnings;
642
646
  }
647
+ function lintSchemas(schemas) {
648
+ return [...schemas.flatMap((schema) => lintSchema(schema)), ...lintSchemaSet(schemas)];
649
+ }
650
+ function lintSchemaSet(schemas) {
651
+ const warnings = [];
652
+ if (!schemas.some((schema) => schema.name === "team_members")) {
653
+ for (const schema of schemas) {
654
+ const teamRoles = Object.entries(schema.permissions).filter(([, p]) => [p.read, p.update, p.delete].includes("team")).map(([role]) => role);
655
+ if (teamRoles.length > 0) {
656
+ warnings.push(
657
+ `[${schema.name}] role(s) ${teamRoles.join(", ")} use the 'team' permission level, but no 'team_members' collection is registered. 'team' resolves membership from team_members (teamId, userId, status), so without it those roles are denied everything \u2014 silently. Register a team_members schema or use another level.`
658
+ );
659
+ }
660
+ }
661
+ }
662
+ return warnings;
663
+ }
643
664
  var SchemaRegistry = class {
644
665
  trusted = /* @__PURE__ */ new Map();
645
666
  constructor(schemas = []) {
646
- for (const schema of schemas) {
647
- this.registerTrusted(schema);
648
- }
667
+ for (const w of lintSchemaSet(schemas)) console.warn(`[schema-lint] ${w}`);
668
+ for (const schema of schemas) this.registerTrusted(schema);
649
669
  }
650
670
  registerTrusted(schema) {
651
671
  for (const w of lintSchema(schema)) {
@@ -1035,6 +1055,123 @@ function getMessageType(data) {
1035
1055
  return readVarUint(decoder);
1036
1056
  }
1037
1057
 
1058
+ // src/server/utils/tools.ts
1059
+ var DEFAULT_QUERY_LIMIT = 50;
1060
+ var DEFAULT_DELETE_WHERE_LIMIT = 100;
1061
+ var MAX_DELETE_WHERE_LIMIT = 500;
1062
+ function applyAiToolDefaults(toolName, params) {
1063
+ if (toolName === "records.query" && params.limit === void 0) {
1064
+ return { ...params, limit: DEFAULT_QUERY_LIMIT };
1065
+ }
1066
+ return params;
1067
+ }
1068
+ var BUILT_IN_TOOLS = [
1069
+ // Record tools (structured collections with RBAC)
1070
+ {
1071
+ name: "records.query",
1072
+ description: `Query records from a collection with optional filtering, ordering, and pagination. Returns at most \`limit\` records (default ${DEFAULT_QUERY_LIMIT}). Large result sets are capped at ~30KB: an oversized page is truncated to the leading records that fit and flagged with \`{ truncated, returned, total }\`, so narrow with a \`where\` filter (or page via \`orderBy\` + \`limit\`) to see the rest.`,
1073
+ params: {
1074
+ collection: { type: "string", description: "Collection name to query", required: true },
1075
+ where: { type: "object", description: "Filter object with field=value equality conditions; every key must be recordId, createdBy, or a schema column (an unknown key is refused, not ignored)", required: false },
1076
+ orderBy: { type: "string", description: 'Field to order by (or "createdAt"/"updatedAt")', required: false },
1077
+ orderDir: { type: "string", description: 'Order direction: "asc" or "desc" (default: "desc")', required: false },
1078
+ limit: { type: "number", description: `Maximum number of records to return (default: ${DEFAULT_QUERY_LIMIT}). Oversized pages are still capped at ~30KB and truncated with returned/total flags.`, required: false }
1079
+ }
1080
+ },
1081
+ {
1082
+ name: "records.get",
1083
+ description: "Get a single record by ID from a collection",
1084
+ params: {
1085
+ collection: { type: "string", description: "Collection name", required: true },
1086
+ recordId: { type: "string", description: "Record ID to retrieve", required: true }
1087
+ }
1088
+ },
1089
+ {
1090
+ name: "records.create",
1091
+ description: "Create a new record in a collection",
1092
+ params: {
1093
+ collection: { type: "string", description: "Collection name", required: true },
1094
+ data: { type: "object", description: "Record data matching the collection schema", required: true },
1095
+ recordId: { type: "string", description: "Custom record ID (auto-generated if omitted)", required: false }
1096
+ }
1097
+ },
1098
+ {
1099
+ name: "records.update",
1100
+ description: "Update an existing record in a collection",
1101
+ params: {
1102
+ collection: { type: "string", description: "Collection name", required: true },
1103
+ recordId: { type: "string", description: "Record ID to update", required: true },
1104
+ data: { type: "object", description: "Fields to update (merged with existing data)", required: true }
1105
+ }
1106
+ },
1107
+ {
1108
+ name: "records.delete",
1109
+ description: "Delete a record from a collection",
1110
+ params: {
1111
+ collection: { type: "string", description: "Collection name", required: true },
1112
+ recordId: { type: "string", description: "Record ID to delete", required: true }
1113
+ }
1114
+ },
1115
+ {
1116
+ name: "records.deleteWhere",
1117
+ description: `Delete every record matching a filter, in one bounded batch. Deletes at most \`limit\` records (default ${DEFAULT_DELETE_WHERE_LIMIT}, max ${MAX_DELETE_WHERE_LIMIT}) and returns \`{ deleted }\`; repeat the same call until \`deleted\` is less than the limit to drain a larger set. A non-empty \`where\` whose every key names a real field (\`recordId\`, \`createdBy\`, or a schema column) is required \u2014 an unknown key is refused rather than ignored, so this tool never deletes a whole collection. Every matched record goes through the same delete permission check as \`records.delete\`; if any of them is denied, nothing in the batch is deleted.`,
1118
+ params: {
1119
+ collection: { type: "string", description: "Collection name (must have a schema)", required: true },
1120
+ where: { type: "object", description: "Filter object with field=value equality conditions; every key must be recordId, createdBy, or a schema column", required: true },
1121
+ limit: { type: "number", description: `Maximum number of records to delete in this call (default: ${DEFAULT_DELETE_WHERE_LIMIT}, max: ${MAX_DELETE_WHERE_LIMIT})`, required: false }
1122
+ }
1123
+ },
1124
+ // Schema tools
1125
+ {
1126
+ name: "schema.list",
1127
+ description: "List all registered collection schemas with their fields and permissions",
1128
+ params: {}
1129
+ },
1130
+ {
1131
+ name: "schema.describe",
1132
+ description: "Get the full schema for a specific collection including field types and role permissions",
1133
+ params: {
1134
+ collection: { type: "string", description: "Collection name to describe", required: true }
1135
+ }
1136
+ },
1137
+ // User tools
1138
+ {
1139
+ name: "user.current",
1140
+ description: "Get the current user's profile (ID, name, email, role)",
1141
+ params: {}
1142
+ },
1143
+ {
1144
+ name: "user.list",
1145
+ description: "List all users in this room",
1146
+ params: {}
1147
+ },
1148
+ // Yjs tools (collaborative document access)
1149
+ {
1150
+ name: "yjs.list",
1151
+ description: "List all Yjs documents stored in this room, showing collection, recordId, fieldName, and last update time",
1152
+ params: {}
1153
+ },
1154
+ {
1155
+ name: "yjs.getText",
1156
+ description: "Read text content from a Yjs document (for fields using useYjsText)",
1157
+ params: {
1158
+ collection: { type: "string", description: "Collection name", required: true },
1159
+ recordId: { type: "string", description: "Record ID", required: true },
1160
+ fieldName: { type: "string", description: 'Field name (e.g., "content")', required: true }
1161
+ }
1162
+ },
1163
+ {
1164
+ name: "yjs.setText",
1165
+ description: "Write text content to a Yjs document (replaces existing text, broadcasts to connected clients)",
1166
+ params: {
1167
+ collection: { type: "string", description: "Collection name", required: true },
1168
+ recordId: { type: "string", description: "Record ID", required: true },
1169
+ fieldName: { type: "string", description: 'Field name (e.g., "content")', required: true },
1170
+ text: { type: "string", description: "The text content to set", required: true }
1171
+ }
1172
+ }
1173
+ ];
1174
+
1038
1175
  // src/shared/protocol/messages.ts
1039
1176
  var EMPTY = Object.freeze({});
1040
1177
  var clientBuild = {
@@ -1436,6 +1573,86 @@ function deleteRecord(ctx, collection, recordId, userId, userRole, skipUserRbac
1436
1573
  notifyTeamMembershipChange(ctx, collection, record);
1437
1574
  return { success: true, data: { deleted: true } };
1438
1575
  }
1576
+ function refuseUnknownWhere(collection, schema, where) {
1577
+ const columns = (schema.columns ?? []).map(resolveColumn);
1578
+ const filterable = ["recordId", "createdBy", ...columns.map((c) => c.name)];
1579
+ const unknown = Object.keys(where).filter(
1580
+ (key) => key !== "recordId" && key !== "createdBy" && !columns.some((c) => c.id === columnId(key) || c.name === key)
1581
+ );
1582
+ if (unknown.length > 0) {
1583
+ return {
1584
+ success: false,
1585
+ error: `Unknown field(s) in where for "${collection}": ${unknown.join(", ")} \u2014 filterable fields: ${filterable.join(", ")}`
1586
+ };
1587
+ }
1588
+ const nonPrimitive = Object.entries(where).filter(([, v]) => v !== null && typeof v === "object");
1589
+ if (nonPrimitive.length > 0) {
1590
+ return {
1591
+ success: false,
1592
+ error: `where values must be primitives (equality only): ${nonPrimitive.map(([k]) => k).join(", ")}`
1593
+ };
1594
+ }
1595
+ return null;
1596
+ }
1597
+ function deleteWhere(ctx, collection, where, limit, userId, userRole, skipUserRbac = false) {
1598
+ const resolved = resolveCollection(ctx, collection);
1599
+ if (!resolved.ok) return { success: false, error: resolved.error };
1600
+ const schema = resolved.schema;
1601
+ if (!schema) {
1602
+ return {
1603
+ success: false,
1604
+ error: `records.deleteWhere: collection "${collection}" has no schema, so it has no filterable fields \u2014 delete its rows by recordId with records.delete`
1605
+ };
1606
+ }
1607
+ if (!where || typeof where !== "object" || Array.isArray(where) || Object.keys(where).length === 0) {
1608
+ return { success: false, error: "Missing required param: where (must match at least one field)" };
1609
+ }
1610
+ const whereRefusal = refuseUnknownWhere(collection, schema, where);
1611
+ if (whereRefusal) return whereRefusal;
1612
+ if (limit !== void 0 && !(typeof limit === "number" && Number.isFinite(limit))) {
1613
+ return { success: false, error: "limit must be a finite number" };
1614
+ }
1615
+ const page = Math.min(
1616
+ Math.max(1, Math.floor(limit ?? DEFAULT_DELETE_WHERE_LIMIT)),
1617
+ MAX_DELETE_WHERE_LIMIT
1618
+ );
1619
+ const scan = { capped: false };
1620
+ const matched = executeQuery(
1621
+ ctx,
1622
+ { collection, where, limit: page },
1623
+ userId,
1624
+ userRole,
1625
+ skipUserRbac,
1626
+ scan
1627
+ );
1628
+ if (scan.capped && matched.length < page) {
1629
+ return {
1630
+ success: false,
1631
+ error: `records.deleteWhere scanned ${MAX_FILTERED_SCAN_ROWS} rows without filling a page of readable matches in "${collection}" \u2014 narrow \`where\` (or delete by recordId) and retry`
1632
+ };
1633
+ }
1634
+ if (!skipUserRbac && schema) {
1635
+ const permCtx = ctx.getPermissionContext();
1636
+ for (const record of matched) {
1637
+ if (!canDelete(schema, userRole, record, userId, permCtx)) {
1638
+ return { success: false, error: `DELETE DENIED: role=${userRole}, collection=${collection}` };
1639
+ }
1640
+ }
1641
+ }
1642
+ let deleted = 0;
1643
+ for (const record of matched) {
1644
+ if (deleteRecord(ctx, collection, record.recordId, userId, userRole, skipUserRbac).success) {
1645
+ deleted++;
1646
+ }
1647
+ }
1648
+ if (deleted < matched.length) {
1649
+ return {
1650
+ success: false,
1651
+ error: `Deleted ${deleted} of ${matched.length} matched record(s) in "${collection}"; the rest failed \u2014 retry`
1652
+ };
1653
+ }
1654
+ return { success: true, data: { deleted } };
1655
+ }
1439
1656
  function readRecord(ctx, collection, recordId, userId, userRole, skipUserRbac = false) {
1440
1657
  const resolved = resolveCollection(ctx, collection);
1441
1658
  if (!resolved.ok) return { success: false, error: resolved.error };
@@ -1771,7 +1988,8 @@ function handleSubscribe(ctx, ws, attachment, payload) {
1771
1988
  }
1772
1989
  function handleUnsubscribe(_ctx, _ws, _attachment, _payload) {
1773
1990
  }
1774
- function executeQuery(ctx, query, userId, userRole, skipUserRbac = false) {
1991
+ var MAX_FILTERED_SCAN_ROWS = 5e3;
1992
+ function executeQuery(ctx, query, userId, userRole, skipUserRbac = false, scanState) {
1775
1993
  const resolved = resolveCollection(ctx, query.collection);
1776
1994
  if (!resolved.ok) return [];
1777
1995
  if (!resolved.schema) {
@@ -1784,7 +2002,8 @@ function executeQuery(ctx, query, userId, userRole, skipUserRbac = false) {
1784
2002
  userId,
1785
2003
  userRole,
1786
2004
  resolved.isSystem,
1787
- skipUserRbac
2005
+ skipUserRbac,
2006
+ scanState
1788
2007
  );
1789
2008
  }
1790
2009
  function executeSystemQuery(ctx, query) {
@@ -1821,7 +2040,7 @@ function preloadUserTeamIds(ctx, userId) {
1821
2040
  );
1822
2041
  return cursor.toArray().map((r) => r.col_teamid);
1823
2042
  }
1824
- function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipUserRbac = false) {
2043
+ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipUserRbac = false, scanState) {
1825
2044
  const columns = (schema.columns ?? []).map(resolveColumn);
1826
2045
  const perms = getRolePermissions(schema, userRole);
1827
2046
  let sql = buildTableSelect(query.collection, columns);
@@ -1829,16 +2048,27 @@ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipU
1829
2048
  const whereClauses = [];
1830
2049
  let skipPerRecordCheck = skipUserRbac;
1831
2050
  if (!skipUserRbac && schema.teamField && perms.read === "team") {
1832
- const filterCol = schema.teamField === "_rowId" ? "_row_id" : columns.find((c) => c.name === schema.teamField)?.id ?? columnId(schema.teamField);
1833
- const teamIds = preloadUserTeamIds(ctx, userId);
1834
- if (teamIds && teamIds.length > 0) {
1835
- const placeholders = teamIds.map(() => "?").join(", ");
1836
- whereClauses.push(`("${filterCol}" IN (${placeholders}) OR _created_by = ?)`);
1837
- params.push(...teamIds, userId);
1838
- } else {
1839
- whereClauses.push(`_created_by = ?`);
2051
+ const filterCol = schema.teamField === "_rowId" ? "_row_id" : columns.find((c) => c.name === schema.teamField)?.id;
2052
+ const teamIds = filterCol ? preloadUserTeamIds(ctx, userId) : null;
2053
+ if (filterCol) {
2054
+ if (teamIds && teamIds.length > 0) {
2055
+ const placeholders = teamIds.map(() => "?").join(", ");
2056
+ whereClauses.push(`("${filterCol}" IN (${placeholders}) OR _created_by = ?)`);
2057
+ params.push(...teamIds, userId);
2058
+ } else {
2059
+ whereClauses.push(`_created_by = ?`);
2060
+ params.push(userId);
2061
+ }
2062
+ skipPerRecordCheck = true;
2063
+ }
2064
+ } else if (!skipUserRbac && perms.read === "own") {
2065
+ const ownerCol = schema.ownerField ? columns.find((c) => c.name === schema.ownerField)?.id : "_created_by";
2066
+ if (ownerCol) {
2067
+ whereClauses.push(`"${ownerCol}" = ?`);
1840
2068
  params.push(userId);
2069
+ skipPerRecordCheck = true;
1841
2070
  }
2071
+ } else if (!skipUserRbac && perms.read === true) {
1842
2072
  skipPerRecordCheck = true;
1843
2073
  }
1844
2074
  if (query.where) {
@@ -1876,27 +2106,44 @@ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipU
1876
2106
  } else {
1877
2107
  sql += ` ORDER BY _created_at DESC`;
1878
2108
  }
1879
- if (query.limit) {
1880
- sql += ` LIMIT ?`;
1881
- params.push(query.limit);
1882
- }
1883
- const cursor = ctx.sql.exec(sql, ...params);
2109
+ const readPage = (limitClause, limitParams) => {
2110
+ const cursor = ctx.sql.exec(sql + limitClause, ...params, ...limitParams);
2111
+ const rows = cursor.toArray();
2112
+ const records = [];
2113
+ for (const row of rows) {
2114
+ const r = row;
2115
+ const record = {
2116
+ recordId: r._row_id,
2117
+ data: rowToData(r, columns),
2118
+ createdBy: r._created_by,
2119
+ createdAt: r._created_at,
2120
+ updatedAt: r._updated_at
2121
+ };
2122
+ if (isSystem || skipPerRecordCheck || canRead(schema, userRole, { data: record.data, createdBy: record.createdBy, recordId: record.recordId }, userId, ctx.getPermissionContext())) {
2123
+ records.push(record);
2124
+ }
2125
+ }
2126
+ return { records, scanned: rows.length };
2127
+ };
2128
+ if (!query.limit) return readPage("", []).records;
2129
+ const rowFiltered = !isSystem && !skipPerRecordCheck;
2130
+ if (!rowFiltered) return readPage(" LIMIT ?", [query.limit]).records;
1884
2131
  const results = [];
1885
- for (const row of cursor.toArray()) {
1886
- const r = row;
1887
- const data = rowToData(r, columns);
1888
- const record = {
1889
- recordId: r._row_id,
1890
- data,
1891
- createdBy: r._created_by,
1892
- createdAt: r._created_at,
1893
- updatedAt: r._updated_at
1894
- };
1895
- if (isSystem || skipPerRecordCheck || canRead(schema, userRole, { data: record.data, createdBy: record.createdBy, recordId: record.recordId }, userId, ctx.getPermissionContext())) {
2132
+ const batch = Math.max(query.limit, 200);
2133
+ for (let offset = 0; ; offset += batch) {
2134
+ const { records, scanned } = readPage(" LIMIT ? OFFSET ?", [batch, offset]);
2135
+ for (const record of records) {
1896
2136
  results.push(record);
2137
+ if (results.length >= query.limit) return results;
2138
+ }
2139
+ if (scanned < batch) return results;
2140
+ if (offset + batch >= MAX_FILTERED_SCAN_ROWS) {
2141
+ if (scanState && readPage(" LIMIT ? OFFSET ?", [1, offset + batch]).scanned > 0) {
2142
+ scanState.capped = true;
2143
+ }
2144
+ return results;
1897
2145
  }
1898
2146
  }
1899
- return results;
1900
2147
  }
1901
2148
  function recordMatchesSubscription(record, collection, query, userId, userRole, schema, ctx) {
1902
2149
  if (query.collection !== collection) return false;
@@ -2093,30 +2340,56 @@ async function registerUser(sql, userId, name, email, imageUrl, isAdmin, default
2093
2340
  );
2094
2341
  return { id: userId, email, name, imageUrl, role, createdAt: now, lastSeenAt: now };
2095
2342
  }
2343
+ function rosterFor(ctx, schema, records, publicRoster, attachment) {
2344
+ if (isAnonymousUserId(attachment.userId)) return [];
2345
+ if (attachment.role === "admin") {
2346
+ const permissionContext = ctx.getPermissionContext();
2347
+ return records.filter((record) => canRead(schema, "admin", record, attachment.userId, permissionContext)).map((record) => ({ id: record.recordId, ...record.data }));
2348
+ }
2349
+ if (!getRolePermissions(schema, attachment.role).read) return [];
2350
+ if (schema.roster === "read-policy") {
2351
+ const permissionContext = ctx.getPermissionContext();
2352
+ const readable = new Set(
2353
+ records.filter((record) => canRead(schema, attachment.role, record, attachment.userId, permissionContext)).map((record) => record.recordId)
2354
+ );
2355
+ return publicRoster.filter((user) => readable.has(user.id));
2356
+ }
2357
+ return publicRoster;
2358
+ }
2359
+ function publicIdentities(records) {
2360
+ return records.map(({ recordId, data }) => ({
2361
+ id: recordId,
2362
+ name: data.name,
2363
+ ...data.imageUrl ? { imageUrl: data.imageUrl } : {},
2364
+ role: data.role
2365
+ }));
2366
+ }
2096
2367
  function handleUserList(ctx, ws, attachment) {
2097
2368
  const schema = ctx.schemaRegistry.get("users");
2098
- const permissionContext = ctx.getPermissionContext();
2099
- const visibleRecords = attachment.userId.startsWith("anon-") || !schema ? [] : getAllUserRecords(ctx.sql, ctx.schemaRegistry).filter(
2100
- (record) => canRead(
2101
- schema,
2102
- attachment.role,
2103
- record,
2104
- attachment.userId,
2105
- permissionContext
2106
- )
2107
- );
2108
- const users = visibleRecords.map((record) => {
2109
- if (attachment.role === "admin") return { id: record.recordId, ...record.data };
2110
- const { name, imageUrl, role } = record.data;
2111
- return {
2112
- id: record.recordId,
2113
- name,
2114
- ...imageUrl ? { imageUrl } : {},
2115
- role
2116
- };
2117
- });
2369
+ if (!schema) {
2370
+ ctx.send(ws, { type: MSG.USER_LIST, payload: { users: [] } });
2371
+ return;
2372
+ }
2373
+ const records = getAllUserRecords(ctx.sql, ctx.schemaRegistry);
2374
+ const users = rosterFor(ctx, schema, records, publicIdentities(records), attachment);
2118
2375
  ctx.send(ws, { type: MSG.USER_LIST, payload: { users } });
2119
2376
  }
2377
+ function broadcastUserList(ctx, except) {
2378
+ const schema = ctx.schemaRegistry.get("users");
2379
+ if (!schema) return;
2380
+ const skip = except instanceof Set ? except : new Set(except ? [except] : []);
2381
+ const records = getAllUserRecords(ctx.sql, ctx.schemaRegistry);
2382
+ const publicRoster = publicIdentities(records);
2383
+ for (const other of ctx.state.getWebSockets()) {
2384
+ if (skip.has(other)) continue;
2385
+ const attachment = other.deserializeAttachment();
2386
+ if (!attachment) continue;
2387
+ ctx.send(other, {
2388
+ type: MSG.USER_LIST,
2389
+ payload: { users: rosterFor(ctx, schema, records, publicRoster, attachment) }
2390
+ });
2391
+ }
2392
+ }
2120
2393
  function handleUserUpdate(ctx, _ws, attachment, payload) {
2121
2394
  const existing = getUserRecord(ctx.sql, attachment.userId, ctx.schemaRegistry);
2122
2395
  if (!existing) return;
@@ -2125,6 +2398,8 @@ function handleUserUpdate(ctx, _ws, attachment, payload) {
2125
2398
  if (payload.email) data.email = payload.email;
2126
2399
  if (payload.imageUrl !== void 0) data.imageUrl = payload.imageUrl || void 0;
2127
2400
  putRecord(ctx, "users", attachment.userId, data, attachment.userId, "admin", true, true);
2401
+ const rosterChanged = data.name !== void 0 && data.name !== existing.data.name || data.imageUrl !== void 0 && data.imageUrl !== existing.data.imageUrl;
2402
+ if (rosterChanged) broadcastUserList(ctx);
2128
2403
  }
2129
2404
  async function handleSetRole(ctx, ws, attachment, payload) {
2130
2405
  if (attachment.role !== "admin") {
@@ -2143,132 +2418,21 @@ async function handleSetRole(ctx, ws, attachment, payload) {
2143
2418
  now,
2144
2419
  payload.userId
2145
2420
  );
2421
+ const closing = /* @__PURE__ */ new Set();
2146
2422
  for (const otherWs of ctx.state.getWebSockets()) {
2147
2423
  const otherAttachment = otherWs.deserializeAttachment();
2148
- if (!otherAttachment) continue;
2149
- if (otherAttachment.userId === payload.userId) {
2150
- try {
2151
- otherWs.close(1008, "role-changed");
2152
- } catch {
2153
- }
2154
- continue;
2155
- }
2156
- if (otherAttachment.role === "admin") {
2157
- handleUserList(ctx, otherWs, otherAttachment);
2424
+ if (otherAttachment?.userId !== payload.userId) continue;
2425
+ closing.add(otherWs);
2426
+ try {
2427
+ otherWs.close(1008, "role-changed");
2428
+ } catch {
2158
2429
  }
2159
2430
  }
2431
+ broadcastUserList(ctx, closing);
2160
2432
  }
2161
2433
 
2162
2434
  // src/server/handlers/tools-api.ts
2163
2435
  import * as Y3 from "yjs";
2164
-
2165
- // src/server/utils/tools.ts
2166
- var DEFAULT_QUERY_LIMIT = 50;
2167
- function applyAiToolDefaults(toolName, params) {
2168
- if (toolName === "records.query" && params.limit === void 0) {
2169
- return { ...params, limit: DEFAULT_QUERY_LIMIT };
2170
- }
2171
- return params;
2172
- }
2173
- var BUILT_IN_TOOLS = [
2174
- // Record tools (structured collections with RBAC)
2175
- {
2176
- name: "records.query",
2177
- description: `Query records from a collection with optional filtering, ordering, and pagination. Returns at most \`limit\` records (default ${DEFAULT_QUERY_LIMIT}). Large result sets are capped at ~30KB: an oversized page is truncated to the leading records that fit and flagged with \`{ truncated, returned, total }\`, so narrow with a \`where\` filter (or page via \`orderBy\` + \`limit\`) to see the rest.`,
2178
- params: {
2179
- collection: { type: "string", description: "Collection name to query", required: true },
2180
- where: { type: "object", description: "Filter object with field=value equality conditions", required: false },
2181
- orderBy: { type: "string", description: 'Field to order by (or "createdAt"/"updatedAt")', required: false },
2182
- orderDir: { type: "string", description: 'Order direction: "asc" or "desc" (default: "desc")', required: false },
2183
- limit: { type: "number", description: `Maximum number of records to return (default: ${DEFAULT_QUERY_LIMIT}). Oversized pages are still capped at ~30KB and truncated with returned/total flags.`, required: false }
2184
- }
2185
- },
2186
- {
2187
- name: "records.get",
2188
- description: "Get a single record by ID from a collection",
2189
- params: {
2190
- collection: { type: "string", description: "Collection name", required: true },
2191
- recordId: { type: "string", description: "Record ID to retrieve", required: true }
2192
- }
2193
- },
2194
- {
2195
- name: "records.create",
2196
- description: "Create a new record in a collection",
2197
- params: {
2198
- collection: { type: "string", description: "Collection name", required: true },
2199
- data: { type: "object", description: "Record data matching the collection schema", required: true },
2200
- recordId: { type: "string", description: "Custom record ID (auto-generated if omitted)", required: false }
2201
- }
2202
- },
2203
- {
2204
- name: "records.update",
2205
- description: "Update an existing record in a collection",
2206
- params: {
2207
- collection: { type: "string", description: "Collection name", required: true },
2208
- recordId: { type: "string", description: "Record ID to update", required: true },
2209
- data: { type: "object", description: "Fields to update (merged with existing data)", required: true }
2210
- }
2211
- },
2212
- {
2213
- name: "records.delete",
2214
- description: "Delete a record from a collection",
2215
- params: {
2216
- collection: { type: "string", description: "Collection name", required: true },
2217
- recordId: { type: "string", description: "Record ID to delete", required: true }
2218
- }
2219
- },
2220
- // Schema tools
2221
- {
2222
- name: "schema.list",
2223
- description: "List all registered collection schemas with their fields and permissions",
2224
- params: {}
2225
- },
2226
- {
2227
- name: "schema.describe",
2228
- description: "Get the full schema for a specific collection including field types and role permissions",
2229
- params: {
2230
- collection: { type: "string", description: "Collection name to describe", required: true }
2231
- }
2232
- },
2233
- // User tools
2234
- {
2235
- name: "user.current",
2236
- description: "Get the current user's profile (ID, name, email, role)",
2237
- params: {}
2238
- },
2239
- {
2240
- name: "user.list",
2241
- description: "List all users in this room",
2242
- params: {}
2243
- },
2244
- // Yjs tools (collaborative document access)
2245
- {
2246
- name: "yjs.list",
2247
- description: "List all Yjs documents stored in this room, showing collection, recordId, fieldName, and last update time",
2248
- params: {}
2249
- },
2250
- {
2251
- name: "yjs.getText",
2252
- description: "Read text content from a Yjs document (for fields using useYjsText)",
2253
- params: {
2254
- collection: { type: "string", description: "Collection name", required: true },
2255
- recordId: { type: "string", description: "Record ID", required: true },
2256
- fieldName: { type: "string", description: 'Field name (e.g., "content")', required: true }
2257
- }
2258
- },
2259
- {
2260
- name: "yjs.setText",
2261
- description: "Write text content to a Yjs document (replaces existing text, broadcasts to connected clients)",
2262
- params: {
2263
- collection: { type: "string", description: "Collection name", required: true },
2264
- recordId: { type: "string", description: "Record ID", required: true },
2265
- fieldName: { type: "string", description: 'Field name (e.g., "content")', required: true },
2266
- text: { type: "string", description: "The text content to set", required: true }
2267
- }
2268
- }
2269
- ];
2270
-
2271
- // src/server/handlers/tools-api.ts
2272
2436
  var CORS_HEADERS = {
2273
2437
  "Access-Control-Allow-Origin": "*",
2274
2438
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
@@ -2351,9 +2515,20 @@ async function executeTool(ctx, toolName, params, userId, userRole, skipUserRbac
2351
2515
  if (!collection) return { success: false, error: "Missing required param: collection" };
2352
2516
  const resolved = resolveCollection(ctx, collection);
2353
2517
  if (!resolved.ok) return { success: false, error: resolved.error };
2518
+ const where = params.where;
2519
+ if (where && typeof where === "object" && Object.keys(where).length > 0) {
2520
+ if (!resolved.schema) {
2521
+ return {
2522
+ success: false,
2523
+ error: `records.query: collection "${collection}" has no schema, so it has no filterable fields \u2014 query it without \`where\``
2524
+ };
2525
+ }
2526
+ const refusal = refuseUnknownWhere(collection, resolved.schema, where);
2527
+ if (refusal) return refusal;
2528
+ }
2354
2529
  const query = {
2355
2530
  collection,
2356
- where: params.where,
2531
+ where,
2357
2532
  orderBy: params.orderBy,
2358
2533
  orderDir: params.orderDir,
2359
2534
  limit: params.limit
@@ -2392,6 +2567,15 @@ async function executeTool(ctx, toolName, params, userId, userRole, skipUserRbac
2392
2567
  if (!recordId) return { success: false, error: "Missing required param: recordId" };
2393
2568
  return deleteRecord(ctx, collection, recordId, userId, userRole, skipUserRbac);
2394
2569
  }
2570
+ // Bounded batch delete — one subrequest per page instead of one per row.
2571
+ // Same authorization posture as `records.delete`; `deleteWhere` itself
2572
+ // validates `where` (every key must name a real field) and `limit`, so no
2573
+ // dispatch path can reach a collection-wide truncation.
2574
+ case "records.deleteWhere": {
2575
+ const collection = params.collection;
2576
+ if (!collection) return { success: false, error: "Missing required param: collection" };
2577
+ return deleteWhere(ctx, collection, params.where, params.limit, userId, userRole, skipUserRbac);
2578
+ }
2395
2579
  // ---- Users ----
2396
2580
  // Bootstrap or refresh the `users` row for a given userId. Same path
2397
2581
  // the SDK uses on WS connect via registerUser, exposed for server
@@ -2939,12 +3123,13 @@ var RecordRoom = class extends BaseRoom {
2939
3123
  const userName = user.userName;
2940
3124
  const userEmail = user.userEmail;
2941
3125
  const userImageUrl = user.userImageUrl;
2942
- const isAuthenticated = !userId.startsWith("anon-");
3126
+ const isAuthenticated = !isAnonymousUserId(userId);
2943
3127
  let attachment;
2944
3128
  if (isAuthenticated) {
2945
3129
  const isOwner2 = this.ownerUserId != null && userId === this.ownerUserId;
2946
3130
  const regStart = Date.now();
2947
3131
  const defaultRole = this.schemaRegistry.get("users")?.defaultRole ?? "member";
3132
+ const before = getUser(this.sql, userId, this.schemaRegistry);
2948
3133
  const registeredUser = await registerUser(
2949
3134
  this.sql,
2950
3135
  userId,
@@ -2956,6 +3141,9 @@ var RecordRoom = class extends BaseRoom {
2956
3141
  this.schemaRegistry
2957
3142
  );
2958
3143
  const regMs = Date.now() - regStart;
3144
+ if (!before || before.name !== registeredUser.name || before.imageUrl !== registeredUser.imageUrl || before.role !== registeredUser.role) {
3145
+ broadcastUserList(this.createUserContext(), ws);
3146
+ }
2959
3147
  attachment = {
2960
3148
  userId: registeredUser.id,
2961
3149
  userName,
@@ -8415,43 +8603,35 @@ async function createChat(stub, userId, opts = {}) {
8415
8603
  return toChatRow(result.record);
8416
8604
  }
8417
8605
  async function updateChat(stub, chatId, userId, patch) {
8606
+ if (!await getChat(stub, chatId, userId)) return false;
8418
8607
  await executeToolAsApp(stub, userId, "records.update", {
8419
8608
  collection: "ai-chats",
8420
8609
  recordId: chatId,
8421
8610
  data: patch
8422
8611
  });
8612
+ return true;
8423
8613
  }
8614
+ var MESSAGE_DELETE_PAGE = 200;
8424
8615
  async function deleteChatCascade(stub, chatId, userId) {
8425
- const list = await executeToolAsApp(stub, userId, "records.query", {
8426
- collection: "ai-messages",
8427
- where: { chatId }
8428
- });
8429
- const errors = [];
8430
- for (const env of list.records) {
8431
- try {
8432
- await executeToolAsApp(stub, userId, "records.delete", {
8616
+ let messagesError = null;
8617
+ try {
8618
+ let deleted = MESSAGE_DELETE_PAGE;
8619
+ while (deleted === MESSAGE_DELETE_PAGE) {
8620
+ const page = await executeToolAsApp(stub, userId, "records.deleteWhere", {
8433
8621
  collection: "ai-messages",
8434
- recordId: env.recordId
8622
+ where: { chatId },
8623
+ limit: MESSAGE_DELETE_PAGE
8435
8624
  });
8436
- } catch (err) {
8437
- errors.push(err);
8625
+ deleted = page.deleted;
8438
8626
  }
8439
- }
8440
- try {
8441
- await executeToolAsApp(stub, userId, "records.delete", {
8442
- collection: "ai-chats",
8443
- recordId: chatId
8444
- });
8445
8627
  } catch (err) {
8446
- errors.push(err);
8447
- }
8448
- if (errors.length > 0) {
8449
- const first = errors[0];
8450
- throw new Error(
8451
- `deleteChatCascade: ${errors.length} delete(s) failed; first: ${first instanceof Error ? first.message : String(first)}`,
8452
- { cause: errors }
8453
- );
8628
+ messagesError = err;
8454
8629
  }
8630
+ await executeToolAsApp(stub, userId, "records.delete", {
8631
+ collection: "ai-chats",
8632
+ recordId: chatId
8633
+ });
8634
+ if (messagesError) throw messagesError;
8455
8635
  }
8456
8636
  async function loadMessages(stub, chatId, userId) {
8457
8637
  const result = await executeToolAsApp(stub, userId, "records.query", {
@@ -8463,6 +8643,7 @@ async function loadMessages(stub, chatId, userId) {
8463
8643
  return result.records.map(toMessageRow);
8464
8644
  }
8465
8645
  async function appendMessage(stub, msg) {
8646
+ if (!await getChat(stub, msg.chatId, msg.userId)) return false;
8466
8647
  const data = {
8467
8648
  chatId: msg.chatId,
8468
8649
  userId: msg.userId,
@@ -8475,6 +8656,7 @@ async function appendMessage(stub, msg) {
8475
8656
  recordId: msg.id,
8476
8657
  data
8477
8658
  });
8659
+ return true;
8478
8660
  }
8479
8661
 
8480
8662
  // src/server/utils/metering.ts
@@ -10176,6 +10358,7 @@ export {
10176
10358
  AI_CHATS_SCHEMA,
10177
10359
  AI_MESSAGES_SCHEMA,
10178
10360
  ALLOWED_BINDING_TYPES,
10361
+ ANONYMOUS_USER_ID_PREFIX,
10179
10362
  APP_ID_RE,
10180
10363
  APP_LOG_EVENT_KEYS,
10181
10364
  APP_NAME_RULES,
@@ -10200,6 +10383,7 @@ export {
10200
10383
  DEEPSPACE_AI_MODELS,
10201
10384
  DEEPSPACE_MODEL_CATALOG_PROVENANCE,
10202
10385
  DEFAULT_CONTEXT_CONFIG,
10386
+ DEFAULT_DELETE_WHERE_LIMIT,
10203
10387
  DEFAULT_DO_MANIFEST,
10204
10388
  DEFAULT_QUERY_LIMIT,
10205
10389
  DEPLOYED_APPS_DEFAULTS,
@@ -10211,6 +10395,7 @@ export {
10211
10395
  JobRoom,
10212
10396
  LOG_LEVELS,
10213
10397
  MAX_APP_FILE_BYTES,
10398
+ MAX_DELETE_WHERE_LIMIT,
10214
10399
  MAX_DEPLOY_ASSET_FILE_BYTES,
10215
10400
  MAX_UPLOAD_PARTS,
10216
10401
  MAX_UPLOAD_REQUEST_BYTES,
@@ -10311,6 +10496,7 @@ export {
10311
10496
  handleClientErrorReport,
10312
10497
  handleSyncMessage,
10313
10498
  hasContent,
10499
+ isAnonymousUserId,
10314
10500
  isAssetNotFoundHandling,
10315
10501
  isAutoProvision,
10316
10502
  isFilesVerbPath,
@@ -10320,6 +10506,8 @@ export {
10320
10506
  isTestAccountEmail,
10321
10507
  isTestAccountTier,
10322
10508
  lintSchema,
10509
+ lintSchemaSet,
10510
+ lintSchemas,
10323
10511
  listDeepSpaceAgentModels,
10324
10512
  loadMessages,
10325
10513
  makeDefaultSummarizer,