deepspace 0.22.1 → 0.23.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 +29 -0
- package/dist/build.d.ts +116 -0
- package/dist/build.js +139 -0
- package/dist/build.js.map +1 -0
- package/dist/{chunk-XXOLIJB5.js → chunk-UUCAFDYW.js} +2 -2
- package/dist/{chunk-XXOLIJB5.js.map → chunk-UUCAFDYW.js.map} +1 -1
- package/dist/cli.js +978 -633
- package/dist/cli.js.map +1 -1
- package/dist/documentation-client-core.js +1 -1
- package/dist/documentation-default-renderer.cjs +1 -1
- package/dist/documentation-react.js +1 -1
- package/dist/documentation-runtime.js +1 -1
- package/dist/documentation-server-core.js +1 -1
- package/dist/documentation.js +1 -1
- package/dist/documentation.js.map +1 -1
- package/dist/index.d.ts +41 -7
- package/dist/index.js +29 -29
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +10 -0
- package/dist/schema.js +6 -0
- package/dist/schema.js.map +1 -1
- package/dist/server.d.ts +134 -25
- package/dist/server.js +450 -234
- package/dist/server.js.map +1 -1
- package/dist/testing.d.ts +2 -0
- package/dist/testing.js +21 -9
- package/dist/testing.js.map +1 -1
- package/dist/worker.d.ts +123 -14
- package/dist/worker.js +558 -342
- package/dist/worker.js.map +1 -1
- package/package.json +5 -1
package/dist/server.js
CHANGED
|
@@ -96,6 +96,8 @@ function registryErrorJson(c, err) {
|
|
|
96
96
|
|
|
97
97
|
// src/server/utils/tools.ts
|
|
98
98
|
var DEFAULT_QUERY_LIMIT = 50;
|
|
99
|
+
var DEFAULT_DELETE_WHERE_LIMIT = 100;
|
|
100
|
+
var MAX_DELETE_WHERE_LIMIT = 500;
|
|
99
101
|
function applyAiToolDefaults(toolName, params) {
|
|
100
102
|
if (toolName === "records.query" && params.limit === void 0) {
|
|
101
103
|
return { ...params, limit: DEFAULT_QUERY_LIMIT };
|
|
@@ -149,6 +151,15 @@ var BUILT_IN_TOOLS = [
|
|
|
149
151
|
recordId: { type: "string", description: "Record ID to delete", required: true }
|
|
150
152
|
}
|
|
151
153
|
},
|
|
154
|
+
{
|
|
155
|
+
name: "records.deleteWhere",
|
|
156
|
+
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.`,
|
|
157
|
+
params: {
|
|
158
|
+
collection: { type: "string", description: "Collection name (must have a schema)", required: true },
|
|
159
|
+
where: { type: "object", description: "Filter object with field=value equality conditions; every key must be recordId, createdBy, or a schema column", required: true },
|
|
160
|
+
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 }
|
|
161
|
+
}
|
|
162
|
+
},
|
|
152
163
|
// Schema tools
|
|
153
164
|
{
|
|
154
165
|
name: "schema.list",
|
|
@@ -1586,7 +1597,7 @@ function authWorkerFetch(env, path, init) {
|
|
|
1586
1597
|
function buildCronContext(env, ownerUserId, roomId = "default") {
|
|
1587
1598
|
const roomIdObj = env.RECORD_ROOMS.idFromName(roomId);
|
|
1588
1599
|
const room = env.RECORD_ROOMS.get(roomIdObj);
|
|
1589
|
-
async function
|
|
1600
|
+
async function executeTool2(tool2, params) {
|
|
1590
1601
|
const response = await room.fetch(
|
|
1591
1602
|
new Request("https://internal/api/tools/execute", {
|
|
1592
1603
|
method: "POST",
|
|
@@ -1609,18 +1620,18 @@ function buildCronContext(env, ownerUserId, roomId = "default") {
|
|
|
1609
1620
|
const params = { collection };
|
|
1610
1621
|
if (opts?.where) params.where = opts.where;
|
|
1611
1622
|
if (opts?.limit) params.limit = opts.limit;
|
|
1612
|
-
const data = await
|
|
1623
|
+
const data = await executeTool2("records.query", params);
|
|
1613
1624
|
const records2 = data?.records;
|
|
1614
1625
|
return records2 ?? [];
|
|
1615
1626
|
},
|
|
1616
1627
|
async create(collection, data) {
|
|
1617
|
-
return
|
|
1628
|
+
return executeTool2("records.create", { collection, data });
|
|
1618
1629
|
},
|
|
1619
1630
|
async update(collection, recordId, data) {
|
|
1620
|
-
return
|
|
1631
|
+
return executeTool2("records.update", { collection, recordId, data });
|
|
1621
1632
|
},
|
|
1622
1633
|
async delete(collection, recordId) {
|
|
1623
|
-
return
|
|
1634
|
+
return executeTool2("records.delete", { collection, recordId });
|
|
1624
1635
|
}
|
|
1625
1636
|
};
|
|
1626
1637
|
const integrations = {
|
|
@@ -2468,8 +2479,86 @@ function makeDefaultSummarizer(env, options = {}) {
|
|
|
2468
2479
|
};
|
|
2469
2480
|
}
|
|
2470
2481
|
|
|
2482
|
+
// src/shared/protocol/constants.ts
|
|
2483
|
+
var MSG = {
|
|
2484
|
+
// ---- Records / CRUD --------------------------------------------------
|
|
2485
|
+
SUBSCRIBE: "core.subscribe",
|
|
2486
|
+
UNSUBSCRIBE: "core.unsubscribe",
|
|
2487
|
+
QUERY_RESULT: "core.query_result",
|
|
2488
|
+
RECORD_CHANGE: "core.record_change",
|
|
2489
|
+
PUT: "core.put",
|
|
2490
|
+
DELETE: "core.delete",
|
|
2491
|
+
ERROR: "core.error",
|
|
2492
|
+
// ---- Users -----------------------------------------------------------
|
|
2493
|
+
USER_INFO: "user.info",
|
|
2494
|
+
USER_LIST: "user.list",
|
|
2495
|
+
SET_ROLE: "user.set_role",
|
|
2496
|
+
USER_UPDATE: "user.update",
|
|
2497
|
+
// ---- Auth signal -----------------------------------------------------
|
|
2498
|
+
// Server → client one-shot on connect from rooms that enforce role-based
|
|
2499
|
+
// writes (Canvas, Game, Cron). Tells the client whether this connection
|
|
2500
|
+
// can write so its hook can disable local write APIs and the UI can
|
|
2501
|
+
// render controls accordingly. Without this, a viewer's click would
|
|
2502
|
+
// round-trip to the server and silently come back as ERROR.
|
|
2503
|
+
AUTH: "auth",
|
|
2504
|
+
// ---- Yjs JSON control frames (binary sync lives in ./yjs.ts) ---------
|
|
2505
|
+
YJS_JOIN: "yjs.join",
|
|
2506
|
+
YJS_LEAVE: "yjs.leave",
|
|
2507
|
+
// ---- Records room misc -----------------------------------------------
|
|
2508
|
+
ACK: "records.ack",
|
|
2509
|
+
LIST_SCHEMAS: "records.list_schemas",
|
|
2510
|
+
RESUBSCRIBE: "records.resubscribe",
|
|
2511
|
+
// ---- CanvasRoom ------------------------------------------------------
|
|
2512
|
+
CANVAS_SHAPES: "canvas.shapes",
|
|
2513
|
+
CANVAS_ADD: "canvas.add",
|
|
2514
|
+
CANVAS_MOVE: "canvas.move",
|
|
2515
|
+
CANVAS_RESIZE: "canvas.resize",
|
|
2516
|
+
CANVAS_DELETE: "canvas.delete",
|
|
2517
|
+
CANVAS_UPDATE: "canvas.update",
|
|
2518
|
+
CANVAS_VIEWPORT: "canvas.viewport",
|
|
2519
|
+
CANVAS_UNDO: "canvas.undo",
|
|
2520
|
+
CANVAS_REDO: "canvas.redo",
|
|
2521
|
+
// ---- CronRoom --------------------------------------------------------
|
|
2522
|
+
CRON_TASKS: "cron.tasks",
|
|
2523
|
+
CRON_HISTORY: "cron.history",
|
|
2524
|
+
CRON_TRIGGER: "cron.trigger",
|
|
2525
|
+
CRON_PAUSE: "cron.pause",
|
|
2526
|
+
CRON_RESUME: "cron.resume",
|
|
2527
|
+
CRON_STATUS: "cron.status",
|
|
2528
|
+
// Per-mutation receipt, sent only to the socket whose trigger/pause/
|
|
2529
|
+
// resume carried a `requestId` — frames without one stay fire-and-forget.
|
|
2530
|
+
CRON_ACK: "cron.ack",
|
|
2531
|
+
// ---- JobRoom ---------------------------------------------------------
|
|
2532
|
+
// Background-job queue with durable, alarm-driven execution. See
|
|
2533
|
+
// server/rooms/job-room.ts. Clients enqueue and subscribe to updates;
|
|
2534
|
+
// the room broadcasts JOB_UPDATE for every status transition.
|
|
2535
|
+
JOB_ENQUEUE: "job.enqueue",
|
|
2536
|
+
JOB_CANCEL: "job.cancel",
|
|
2537
|
+
JOB_RETRY: "job.retry",
|
|
2538
|
+
JOB_UPDATE: "job.update",
|
|
2539
|
+
// ---- PresenceRoom ----------------------------------------------------
|
|
2540
|
+
PRESENCE_SYNC: "presence.sync",
|
|
2541
|
+
PRESENCE_JOIN: "presence.join",
|
|
2542
|
+
PRESENCE_LEAVE: "presence.leave",
|
|
2543
|
+
PRESENCE_UPDATE: "presence.update",
|
|
2544
|
+
// ---- Gateway multiplexing --------------------------------------------
|
|
2545
|
+
GW_SCOPE_CONNECT: "gateway.scope_connect",
|
|
2546
|
+
GW_SCOPE_DISCONNECT: "gateway.scope_disconnect",
|
|
2547
|
+
GW_SCOPE_ERROR: "gateway.scope_error",
|
|
2548
|
+
GW_TOKEN_REFRESH: "gateway.token_refresh",
|
|
2549
|
+
GW_USER_UPDATE: "gateway.user_update"
|
|
2550
|
+
};
|
|
2551
|
+
var MSG_YJS_SYNC = 22;
|
|
2552
|
+
var MSG_YJS_AWARENESS = 23;
|
|
2553
|
+
var ROLE_ANONYMOUS = "viewer";
|
|
2554
|
+
var ANONYMOUS_USER_ID_PREFIX = "anon-";
|
|
2555
|
+
function isAnonymousUserId(userId) {
|
|
2556
|
+
return userId.startsWith(ANONYMOUS_USER_ID_PREFIX);
|
|
2557
|
+
}
|
|
2558
|
+
var RECORD_NOT_FOUND = "Record not found";
|
|
2559
|
+
|
|
2471
2560
|
// src/server/utils/chat-history.ts
|
|
2472
|
-
async function
|
|
2561
|
+
async function executeToolAsApp(stub, userId, tool2, params) {
|
|
2473
2562
|
const res = await stub.fetch(new Request("https://internal/api/tools/execute", {
|
|
2474
2563
|
method: "POST",
|
|
2475
2564
|
headers: {
|
|
@@ -2510,13 +2599,15 @@ function toMessageRow(env) {
|
|
|
2510
2599
|
}
|
|
2511
2600
|
async function getChat(stub, chatId, userId) {
|
|
2512
2601
|
try {
|
|
2513
|
-
const result = await
|
|
2602
|
+
const result = await executeToolAsApp(stub, userId, "records.get", {
|
|
2514
2603
|
collection: "ai-chats",
|
|
2515
2604
|
recordId: chatId
|
|
2516
2605
|
});
|
|
2517
|
-
|
|
2606
|
+
if (!result.record) return null;
|
|
2607
|
+
const chat = toChatRow(result.record);
|
|
2608
|
+
return chat.userId === userId ? chat : null;
|
|
2518
2609
|
} catch (err) {
|
|
2519
|
-
if (err instanceof Error && err.message.includes(
|
|
2610
|
+
if (err instanceof Error && err.message.includes(RECORD_NOT_FOUND)) return null;
|
|
2520
2611
|
throw err;
|
|
2521
2612
|
}
|
|
2522
2613
|
}
|
|
@@ -2524,53 +2615,45 @@ async function createChat(stub, userId, opts = {}) {
|
|
|
2524
2615
|
const data = { userId };
|
|
2525
2616
|
if (opts.title !== void 0) data.title = opts.title;
|
|
2526
2617
|
if (opts.model !== void 0) data.model = opts.model;
|
|
2527
|
-
const result = await
|
|
2618
|
+
const result = await executeToolAsApp(stub, userId, "records.create", {
|
|
2528
2619
|
collection: "ai-chats",
|
|
2529
2620
|
data
|
|
2530
2621
|
});
|
|
2531
2622
|
return toChatRow(result.record);
|
|
2532
2623
|
}
|
|
2533
2624
|
async function updateChat(stub, chatId, userId, patch) {
|
|
2534
|
-
await
|
|
2625
|
+
if (!await getChat(stub, chatId, userId)) return false;
|
|
2626
|
+
await executeToolAsApp(stub, userId, "records.update", {
|
|
2535
2627
|
collection: "ai-chats",
|
|
2536
2628
|
recordId: chatId,
|
|
2537
2629
|
data: patch
|
|
2538
2630
|
});
|
|
2631
|
+
return true;
|
|
2539
2632
|
}
|
|
2633
|
+
var MESSAGE_DELETE_PAGE = 200;
|
|
2540
2634
|
async function deleteChatCascade(stub, chatId, userId) {
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
for (const env of list.records) {
|
|
2547
|
-
try {
|
|
2548
|
-
await executeTool(stub, userId, "records.delete", {
|
|
2635
|
+
let messagesError = null;
|
|
2636
|
+
try {
|
|
2637
|
+
let deleted = MESSAGE_DELETE_PAGE;
|
|
2638
|
+
while (deleted === MESSAGE_DELETE_PAGE) {
|
|
2639
|
+
const page = await executeToolAsApp(stub, userId, "records.deleteWhere", {
|
|
2549
2640
|
collection: "ai-messages",
|
|
2550
|
-
|
|
2641
|
+
where: { chatId },
|
|
2642
|
+
limit: MESSAGE_DELETE_PAGE
|
|
2551
2643
|
});
|
|
2552
|
-
|
|
2553
|
-
errors.push(err);
|
|
2644
|
+
deleted = page.deleted;
|
|
2554
2645
|
}
|
|
2555
|
-
}
|
|
2556
|
-
try {
|
|
2557
|
-
await executeTool(stub, userId, "records.delete", {
|
|
2558
|
-
collection: "ai-chats",
|
|
2559
|
-
recordId: chatId
|
|
2560
|
-
});
|
|
2561
2646
|
} catch (err) {
|
|
2562
|
-
|
|
2563
|
-
}
|
|
2564
|
-
if (errors.length > 0) {
|
|
2565
|
-
const first = errors[0];
|
|
2566
|
-
throw new Error(
|
|
2567
|
-
`deleteChatCascade: ${errors.length} delete(s) failed; first: ${first instanceof Error ? first.message : String(first)}`,
|
|
2568
|
-
{ cause: errors }
|
|
2569
|
-
);
|
|
2647
|
+
messagesError = err;
|
|
2570
2648
|
}
|
|
2649
|
+
await executeToolAsApp(stub, userId, "records.delete", {
|
|
2650
|
+
collection: "ai-chats",
|
|
2651
|
+
recordId: chatId
|
|
2652
|
+
});
|
|
2653
|
+
if (messagesError) throw messagesError;
|
|
2571
2654
|
}
|
|
2572
2655
|
async function loadMessages(stub, chatId, userId) {
|
|
2573
|
-
const result = await
|
|
2656
|
+
const result = await executeToolAsApp(stub, userId, "records.query", {
|
|
2574
2657
|
collection: "ai-messages",
|
|
2575
2658
|
where: { chatId, userId },
|
|
2576
2659
|
orderBy: "createdAt",
|
|
@@ -2579,6 +2662,7 @@ async function loadMessages(stub, chatId, userId) {
|
|
|
2579
2662
|
return result.records.map(toMessageRow);
|
|
2580
2663
|
}
|
|
2581
2664
|
async function appendMessage(stub, msg) {
|
|
2665
|
+
if (!await getChat(stub, msg.chatId, msg.userId)) return false;
|
|
2582
2666
|
const data = {
|
|
2583
2667
|
chatId: msg.chatId,
|
|
2584
2668
|
userId: msg.userId,
|
|
@@ -2586,11 +2670,12 @@ async function appendMessage(stub, msg) {
|
|
|
2586
2670
|
content: msg.content
|
|
2587
2671
|
};
|
|
2588
2672
|
if (msg.parts !== void 0) data.parts = msg.parts;
|
|
2589
|
-
await
|
|
2673
|
+
await executeToolAsApp(stub, msg.userId, "records.create", {
|
|
2590
2674
|
collection: "ai-messages",
|
|
2591
2675
|
recordId: msg.id,
|
|
2592
2676
|
data
|
|
2593
2677
|
});
|
|
2678
|
+
return true;
|
|
2594
2679
|
}
|
|
2595
2680
|
|
|
2596
2681
|
// src/server/utils/metering.ts
|
|
@@ -2713,7 +2798,7 @@ function decodeRoomIdentityHeader(value) {
|
|
|
2713
2798
|
// src/server/rooms/base-room.ts
|
|
2714
2799
|
function connectionAttachmentFromRequest(request) {
|
|
2715
2800
|
return {
|
|
2716
|
-
userId: decodeRoomIdentityHeader(request.headers.get("x-user-id")) ||
|
|
2801
|
+
userId: decodeRoomIdentityHeader(request.headers.get("x-user-id")) || `${ANONYMOUS_USER_ID_PREFIX}${crypto.randomUUID()}`,
|
|
2717
2802
|
userName: decodeRoomIdentityHeader(request.headers.get("x-user-name")) || "Anonymous",
|
|
2718
2803
|
userEmail: decodeRoomIdentityHeader(request.headers.get("x-user-email")) || "",
|
|
2719
2804
|
userImageUrl: decodeRoomIdentityHeader(request.headers.get("x-user-image-url")),
|
|
@@ -2967,79 +3052,6 @@ var BaseRoom = class {
|
|
|
2967
3052
|
}
|
|
2968
3053
|
};
|
|
2969
3054
|
|
|
2970
|
-
// src/shared/protocol/constants.ts
|
|
2971
|
-
var MSG = {
|
|
2972
|
-
// ---- Records / CRUD --------------------------------------------------
|
|
2973
|
-
SUBSCRIBE: "core.subscribe",
|
|
2974
|
-
UNSUBSCRIBE: "core.unsubscribe",
|
|
2975
|
-
QUERY_RESULT: "core.query_result",
|
|
2976
|
-
RECORD_CHANGE: "core.record_change",
|
|
2977
|
-
PUT: "core.put",
|
|
2978
|
-
DELETE: "core.delete",
|
|
2979
|
-
ERROR: "core.error",
|
|
2980
|
-
// ---- Users -----------------------------------------------------------
|
|
2981
|
-
USER_INFO: "user.info",
|
|
2982
|
-
USER_LIST: "user.list",
|
|
2983
|
-
SET_ROLE: "user.set_role",
|
|
2984
|
-
USER_UPDATE: "user.update",
|
|
2985
|
-
// ---- Auth signal -----------------------------------------------------
|
|
2986
|
-
// Server → client one-shot on connect from rooms that enforce role-based
|
|
2987
|
-
// writes (Canvas, Game, Cron). Tells the client whether this connection
|
|
2988
|
-
// can write so its hook can disable local write APIs and the UI can
|
|
2989
|
-
// render controls accordingly. Without this, a viewer's click would
|
|
2990
|
-
// round-trip to the server and silently come back as ERROR.
|
|
2991
|
-
AUTH: "auth",
|
|
2992
|
-
// ---- Yjs JSON control frames (binary sync lives in ./yjs.ts) ---------
|
|
2993
|
-
YJS_JOIN: "yjs.join",
|
|
2994
|
-
YJS_LEAVE: "yjs.leave",
|
|
2995
|
-
// ---- Records room misc -----------------------------------------------
|
|
2996
|
-
ACK: "records.ack",
|
|
2997
|
-
LIST_SCHEMAS: "records.list_schemas",
|
|
2998
|
-
RESUBSCRIBE: "records.resubscribe",
|
|
2999
|
-
// ---- CanvasRoom ------------------------------------------------------
|
|
3000
|
-
CANVAS_SHAPES: "canvas.shapes",
|
|
3001
|
-
CANVAS_ADD: "canvas.add",
|
|
3002
|
-
CANVAS_MOVE: "canvas.move",
|
|
3003
|
-
CANVAS_RESIZE: "canvas.resize",
|
|
3004
|
-
CANVAS_DELETE: "canvas.delete",
|
|
3005
|
-
CANVAS_UPDATE: "canvas.update",
|
|
3006
|
-
CANVAS_VIEWPORT: "canvas.viewport",
|
|
3007
|
-
CANVAS_UNDO: "canvas.undo",
|
|
3008
|
-
CANVAS_REDO: "canvas.redo",
|
|
3009
|
-
// ---- CronRoom --------------------------------------------------------
|
|
3010
|
-
CRON_TASKS: "cron.tasks",
|
|
3011
|
-
CRON_HISTORY: "cron.history",
|
|
3012
|
-
CRON_TRIGGER: "cron.trigger",
|
|
3013
|
-
CRON_PAUSE: "cron.pause",
|
|
3014
|
-
CRON_RESUME: "cron.resume",
|
|
3015
|
-
CRON_STATUS: "cron.status",
|
|
3016
|
-
// Per-mutation receipt, sent only to the socket whose trigger/pause/
|
|
3017
|
-
// resume carried a `requestId` — frames without one stay fire-and-forget.
|
|
3018
|
-
CRON_ACK: "cron.ack",
|
|
3019
|
-
// ---- JobRoom ---------------------------------------------------------
|
|
3020
|
-
// Background-job queue with durable, alarm-driven execution. See
|
|
3021
|
-
// server/rooms/job-room.ts. Clients enqueue and subscribe to updates;
|
|
3022
|
-
// the room broadcasts JOB_UPDATE for every status transition.
|
|
3023
|
-
JOB_ENQUEUE: "job.enqueue",
|
|
3024
|
-
JOB_CANCEL: "job.cancel",
|
|
3025
|
-
JOB_RETRY: "job.retry",
|
|
3026
|
-
JOB_UPDATE: "job.update",
|
|
3027
|
-
// ---- PresenceRoom ----------------------------------------------------
|
|
3028
|
-
PRESENCE_SYNC: "presence.sync",
|
|
3029
|
-
PRESENCE_JOIN: "presence.join",
|
|
3030
|
-
PRESENCE_LEAVE: "presence.leave",
|
|
3031
|
-
PRESENCE_UPDATE: "presence.update",
|
|
3032
|
-
// ---- Gateway multiplexing --------------------------------------------
|
|
3033
|
-
GW_SCOPE_CONNECT: "gateway.scope_connect",
|
|
3034
|
-
GW_SCOPE_DISCONNECT: "gateway.scope_disconnect",
|
|
3035
|
-
GW_SCOPE_ERROR: "gateway.scope_error",
|
|
3036
|
-
GW_TOKEN_REFRESH: "gateway.token_refresh",
|
|
3037
|
-
GW_USER_UPDATE: "gateway.user_update"
|
|
3038
|
-
};
|
|
3039
|
-
var MSG_YJS_SYNC = 22;
|
|
3040
|
-
var MSG_YJS_AWARENESS = 23;
|
|
3041
|
-
var ROLE_ANONYMOUS = "viewer";
|
|
3042
|
-
|
|
3043
3055
|
// src/server/schemas/registry.ts
|
|
3044
3056
|
function collectionTableName(name) {
|
|
3045
3057
|
return `c_${name.replace(/[^a-zA-Z0-9_]/g, "_")}`;
|
|
@@ -3241,13 +3253,11 @@ function checkUnclaimedOwnerTransition(schema, role, nextData, userId) {
|
|
|
3241
3253
|
}
|
|
3242
3254
|
return `Role '${role}' can set '${schema.ownerField}' only to their own user id or leave it unclaimed`;
|
|
3243
3255
|
}
|
|
3256
|
+
var SYSTEM_ASSIGNED_COLUMNS = /* @__PURE__ */ new Set(["email", "name", "imageUrl", "role"]);
|
|
3257
|
+
var SYSTEM_MAINTAINED_COLUMNS = /* @__PURE__ */ new Set(["createdAt", "lastSeenAt"]);
|
|
3244
3258
|
var SYSTEM_MANAGED_COLUMNS = /* @__PURE__ */ new Set([
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
"imageUrl",
|
|
3248
|
-
"role",
|
|
3249
|
-
"createdAt",
|
|
3250
|
-
"lastSeenAt"
|
|
3259
|
+
...SYSTEM_ASSIGNED_COLUMNS,
|
|
3260
|
+
...SYSTEM_MAINTAINED_COLUMNS
|
|
3251
3261
|
]);
|
|
3252
3262
|
var USERS_COLUMNS = [
|
|
3253
3263
|
{ name: "email", storage: "text", interpretation: "plain" },
|
|
@@ -3301,12 +3311,28 @@ function lintSchema(schema) {
|
|
|
3301
3311
|
}
|
|
3302
3312
|
return warnings;
|
|
3303
3313
|
}
|
|
3314
|
+
function lintSchemas(schemas) {
|
|
3315
|
+
return [...schemas.flatMap((schema) => lintSchema(schema)), ...lintSchemaSet(schemas)];
|
|
3316
|
+
}
|
|
3317
|
+
function lintSchemaSet(schemas) {
|
|
3318
|
+
const warnings = [];
|
|
3319
|
+
if (!schemas.some((schema) => schema.name === "team_members")) {
|
|
3320
|
+
for (const schema of schemas) {
|
|
3321
|
+
const teamRoles = Object.entries(schema.permissions).filter(([, p]) => [p.read, p.update, p.delete].includes("team")).map(([role]) => role);
|
|
3322
|
+
if (teamRoles.length > 0) {
|
|
3323
|
+
warnings.push(
|
|
3324
|
+
`[${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.`
|
|
3325
|
+
);
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
return warnings;
|
|
3330
|
+
}
|
|
3304
3331
|
var SchemaRegistry = class {
|
|
3305
3332
|
trusted = /* @__PURE__ */ new Map();
|
|
3306
3333
|
constructor(schemas = []) {
|
|
3307
|
-
for (const
|
|
3308
|
-
|
|
3309
|
-
}
|
|
3334
|
+
for (const w of lintSchemaSet(schemas)) console.warn(`[schema-lint] ${w}`);
|
|
3335
|
+
for (const schema of schemas) this.registerTrusted(schema);
|
|
3310
3336
|
}
|
|
3311
3337
|
registerTrusted(schema) {
|
|
3312
3338
|
for (const w of lintSchema(schema)) {
|
|
@@ -3553,6 +3579,11 @@ var serverBuild = {
|
|
|
3553
3579
|
function getResolvedColumns(schema) {
|
|
3554
3580
|
return (schema.columns ?? []).map(resolveColumn);
|
|
3555
3581
|
}
|
|
3582
|
+
function systemManagedColumnError(column, isUpdate) {
|
|
3583
|
+
const roleHint = column === "role" ? ", or the admin-gated useUsers().setRole() to change a role" : "";
|
|
3584
|
+
const remedy = isUpdate ? `Re-sending the current value of '${column}' unchanged is accepted and ignored.` : `A users row is created by registration, so no value for '${column}' is accepted here \u2014 omit it.`;
|
|
3585
|
+
return `FIELD ERROR: '${column}' is system-managed and cannot be set by a record write \u2014 nothing was written. Use tools.registerUser({ userId, isAdmin })${roleHint}. ${remedy}`;
|
|
3586
|
+
}
|
|
3556
3587
|
function notifyTeamMembershipChange(ctx, collection, record) {
|
|
3557
3588
|
if (collection !== "team_members") return;
|
|
3558
3589
|
const affectedUserId = record.data.UserId;
|
|
@@ -3623,11 +3654,9 @@ function handleDelete(ctx, ws, attachment, payload) {
|
|
|
3623
3654
|
}
|
|
3624
3655
|
}
|
|
3625
3656
|
function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRbac = false, systemUpdate = false) {
|
|
3626
|
-
const
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
return { success: false, error: `Schema not registered for collection: ${collection}` };
|
|
3630
|
-
}
|
|
3657
|
+
const resolved = resolveCollection(ctx, collection);
|
|
3658
|
+
if (!resolved.ok) return { success: false, error: resolved.error };
|
|
3659
|
+
const schema = resolved.schema;
|
|
3631
3660
|
if (!schema) {
|
|
3632
3661
|
return { success: false, error: `Schema not registered for collection: ${collection}` };
|
|
3633
3662
|
}
|
|
@@ -3682,17 +3711,7 @@ function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRb
|
|
|
3682
3711
|
}
|
|
3683
3712
|
}
|
|
3684
3713
|
}
|
|
3685
|
-
|
|
3686
|
-
if (!systemUpdate && schema.name === "users") {
|
|
3687
|
-
finalData = { ...mergedData };
|
|
3688
|
-
for (const key of SYSTEM_MANAGED_COLUMNS) {
|
|
3689
|
-
if (existing?.data[key] !== void 0) {
|
|
3690
|
-
finalData[key] = existing.data[key];
|
|
3691
|
-
} else {
|
|
3692
|
-
delete finalData[key];
|
|
3693
|
-
}
|
|
3694
|
-
}
|
|
3695
|
-
}
|
|
3714
|
+
const finalData = { ...mergedData };
|
|
3696
3715
|
if (!skipUserRbac) {
|
|
3697
3716
|
const permCtx = ctx.getPermissionContext();
|
|
3698
3717
|
if (isUpdate) {
|
|
@@ -3724,6 +3743,17 @@ function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRb
|
|
|
3724
3743
|
return { success: false, error: `FIELD ERROR: ${ownerError}` };
|
|
3725
3744
|
}
|
|
3726
3745
|
}
|
|
3746
|
+
if (!systemUpdate && schema.name === "users") {
|
|
3747
|
+
for (const col of columns) {
|
|
3748
|
+
if (!SYSTEM_MANAGED_COLUMNS.has(col.name)) continue;
|
|
3749
|
+
const current = existing?.data[col.name];
|
|
3750
|
+
if (SYSTEM_ASSIGNED_COLUMNS.has(col.name) && Object.hasOwn(data, col.name) && coerceValue(data[col.name], col.storage, col.interpretation) !== coerceValue(current, col.storage, col.interpretation)) {
|
|
3751
|
+
return { success: false, error: systemManagedColumnError(col.name, isUpdate) };
|
|
3752
|
+
}
|
|
3753
|
+
if (current !== void 0) finalData[col.name] = current;
|
|
3754
|
+
else delete finalData[col.name];
|
|
3755
|
+
}
|
|
3756
|
+
}
|
|
3727
3757
|
const colValues = dataToColumnValues(finalData, columns);
|
|
3728
3758
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3729
3759
|
const tbl = collectionTableName(collection);
|
|
@@ -3783,14 +3813,12 @@ function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRb
|
|
|
3783
3813
|
return { success: true, data: { recordId, record } };
|
|
3784
3814
|
}
|
|
3785
3815
|
function deleteRecord(ctx, collection, recordId, userId, userRole, skipUserRbac = false) {
|
|
3786
|
-
const
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
return { success: false, error: `Schema not registered for collection: ${collection}` };
|
|
3790
|
-
}
|
|
3816
|
+
const resolved = resolveCollection(ctx, collection);
|
|
3817
|
+
if (!resolved.ok) return { success: false, error: resolved.error };
|
|
3818
|
+
const schema = resolved.schema;
|
|
3791
3819
|
const existing = getRecord(ctx.sql, collection, recordId, schema);
|
|
3792
3820
|
if (!existing) {
|
|
3793
|
-
return { success: false, error:
|
|
3821
|
+
return { success: false, error: RECORD_NOT_FOUND };
|
|
3794
3822
|
}
|
|
3795
3823
|
if (!skipUserRbac && schema && !canDelete(schema, userRole, { ...existing, recordId }, userId, ctx.getPermissionContext())) {
|
|
3796
3824
|
return { success: false, error: `DELETE DENIED: role=${userRole}, collection=${collection}` };
|
|
@@ -3808,15 +3836,88 @@ function deleteRecord(ctx, collection, recordId, userId, userRole, skipUserRbac
|
|
|
3808
3836
|
notifyTeamMembershipChange(ctx, collection, record);
|
|
3809
3837
|
return { success: true, data: { deleted: true } };
|
|
3810
3838
|
}
|
|
3811
|
-
function
|
|
3812
|
-
const
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3839
|
+
function deleteWhere(ctx, collection, where, limit, userId, userRole, skipUserRbac = false) {
|
|
3840
|
+
const resolved = resolveCollection(ctx, collection);
|
|
3841
|
+
if (!resolved.ok) return { success: false, error: resolved.error };
|
|
3842
|
+
const schema = resolved.schema;
|
|
3843
|
+
if (!schema) {
|
|
3844
|
+
return {
|
|
3845
|
+
success: false,
|
|
3846
|
+
error: `records.deleteWhere: collection "${collection}" has no schema, so it has no filterable fields \u2014 delete its rows by recordId with records.delete`
|
|
3847
|
+
};
|
|
3848
|
+
}
|
|
3849
|
+
if (!where || typeof where !== "object" || Array.isArray(where) || Object.keys(where).length === 0) {
|
|
3850
|
+
return { success: false, error: "Missing required param: where (must match at least one field)" };
|
|
3851
|
+
}
|
|
3852
|
+
const columns = (schema.columns ?? []).map(resolveColumn);
|
|
3853
|
+
const filterable = ["recordId", "createdBy", ...columns.map((c) => c.name)];
|
|
3854
|
+
const unknown = Object.keys(where).filter(
|
|
3855
|
+
(key) => key !== "recordId" && key !== "createdBy" && !columns.some((c) => c.id === columnId(key) || c.name === key)
|
|
3856
|
+
);
|
|
3857
|
+
if (unknown.length > 0) {
|
|
3858
|
+
return {
|
|
3859
|
+
success: false,
|
|
3860
|
+
error: `Unknown field(s) in where for "${collection}": ${unknown.join(", ")} \u2014 filterable fields: ${filterable.join(", ")}`
|
|
3861
|
+
};
|
|
3862
|
+
}
|
|
3863
|
+
const nonPrimitive = Object.entries(where).filter(([, v]) => v !== null && typeof v === "object");
|
|
3864
|
+
if (nonPrimitive.length > 0) {
|
|
3865
|
+
return {
|
|
3866
|
+
success: false,
|
|
3867
|
+
error: `where values must be primitives (equality only): ${nonPrimitive.map(([k]) => k).join(", ")}`
|
|
3868
|
+
};
|
|
3869
|
+
}
|
|
3870
|
+
if (limit !== void 0 && !(typeof limit === "number" && Number.isFinite(limit))) {
|
|
3871
|
+
return { success: false, error: "limit must be a finite number" };
|
|
3872
|
+
}
|
|
3873
|
+
const page = Math.min(
|
|
3874
|
+
Math.max(1, Math.floor(limit ?? DEFAULT_DELETE_WHERE_LIMIT)),
|
|
3875
|
+
MAX_DELETE_WHERE_LIMIT
|
|
3876
|
+
);
|
|
3877
|
+
const scan = { capped: false };
|
|
3878
|
+
const matched = executeQuery(
|
|
3879
|
+
ctx,
|
|
3880
|
+
{ collection, where, limit: page },
|
|
3881
|
+
userId,
|
|
3882
|
+
userRole,
|
|
3883
|
+
skipUserRbac,
|
|
3884
|
+
scan
|
|
3885
|
+
);
|
|
3886
|
+
if (scan.capped && matched.length < page) {
|
|
3887
|
+
return {
|
|
3888
|
+
success: false,
|
|
3889
|
+
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`
|
|
3890
|
+
};
|
|
3891
|
+
}
|
|
3892
|
+
if (!skipUserRbac && schema) {
|
|
3893
|
+
const permCtx = ctx.getPermissionContext();
|
|
3894
|
+
for (const record of matched) {
|
|
3895
|
+
if (!canDelete(schema, userRole, record, userId, permCtx)) {
|
|
3896
|
+
return { success: false, error: `DELETE DENIED: role=${userRole}, collection=${collection}` };
|
|
3897
|
+
}
|
|
3898
|
+
}
|
|
3899
|
+
}
|
|
3900
|
+
let deleted = 0;
|
|
3901
|
+
for (const record of matched) {
|
|
3902
|
+
if (deleteRecord(ctx, collection, record.recordId, userId, userRole, skipUserRbac).success) {
|
|
3903
|
+
deleted++;
|
|
3904
|
+
}
|
|
3905
|
+
}
|
|
3906
|
+
if (deleted < matched.length) {
|
|
3907
|
+
return {
|
|
3908
|
+
success: false,
|
|
3909
|
+
error: `Deleted ${deleted} of ${matched.length} matched record(s) in "${collection}"; the rest failed \u2014 retry`
|
|
3910
|
+
};
|
|
3816
3911
|
}
|
|
3912
|
+
return { success: true, data: { deleted } };
|
|
3913
|
+
}
|
|
3914
|
+
function readRecord(ctx, collection, recordId, userId, userRole, skipUserRbac = false) {
|
|
3915
|
+
const resolved = resolveCollection(ctx, collection);
|
|
3916
|
+
if (!resolved.ok) return { success: false, error: resolved.error };
|
|
3917
|
+
const schema = resolved.schema;
|
|
3817
3918
|
const existing = getRecord(ctx.sql, collection, recordId, schema);
|
|
3818
3919
|
if (!existing) {
|
|
3819
|
-
return { success: false, error:
|
|
3920
|
+
return { success: false, error: RECORD_NOT_FOUND };
|
|
3820
3921
|
}
|
|
3821
3922
|
if (!skipUserRbac && schema && !canRead(schema, userRole, { ...existing, recordId }, userId, ctx.getPermissionContext())) {
|
|
3822
3923
|
return { success: false, error: `READ DENIED: role=${userRole}, collection=${collection}` };
|
|
@@ -3847,9 +3948,7 @@ function hasYjsSubscription(attachment, target) {
|
|
|
3847
3948
|
(subscription) => subscription.collection === target.collection && subscription.recordId === target.recordId && subscription.fieldName === target.fieldName
|
|
3848
3949
|
);
|
|
3849
3950
|
}
|
|
3850
|
-
var SYSTEM_COLLECTIONS = /* @__PURE__ */ new Set([
|
|
3851
|
-
"canvas-settings"
|
|
3852
|
-
]);
|
|
3951
|
+
var SYSTEM_COLLECTIONS = /* @__PURE__ */ new Set(["canvas-settings"]);
|
|
3853
3952
|
var SYSTEM_COLLECTION_SCHEMAS = [
|
|
3854
3953
|
{
|
|
3855
3954
|
name: "canvas-settings",
|
|
@@ -3866,10 +3965,7 @@ function getOrCreateYjsDoc(ctx, docKey) {
|
|
|
3866
3965
|
let doc = ctx.yjsDocs.get(docKey);
|
|
3867
3966
|
if (doc) return doc;
|
|
3868
3967
|
doc = new Y2.Doc();
|
|
3869
|
-
const row = ctx.sql.exec(
|
|
3870
|
-
`SELECT state FROM yjs_docs WHERE doc_key = ?`,
|
|
3871
|
-
docKey
|
|
3872
|
-
).toArray()[0];
|
|
3968
|
+
const row = ctx.sql.exec(`SELECT state FROM yjs_docs WHERE doc_key = ?`, docKey).toArray()[0];
|
|
3873
3969
|
if (row?.state) {
|
|
3874
3970
|
try {
|
|
3875
3971
|
Y2.applyUpdate(doc, new Uint8Array(row.state));
|
|
@@ -3934,11 +4030,17 @@ function handleYjsJoin(ctx, ws, attachment, payload) {
|
|
|
3934
4030
|
}
|
|
3935
4031
|
} else {
|
|
3936
4032
|
if (!schema) {
|
|
3937
|
-
|
|
4033
|
+
const resolution = resolveCollection(ctx, collection);
|
|
4034
|
+
ctx.send(ws, {
|
|
4035
|
+
type: MSG.ERROR,
|
|
4036
|
+
payload: {
|
|
4037
|
+
error: resolution.ok ? `Schema not registered for collection: ${collection}` : resolution.error
|
|
4038
|
+
}
|
|
4039
|
+
});
|
|
3938
4040
|
return;
|
|
3939
4041
|
}
|
|
3940
4042
|
if (!record) {
|
|
3941
|
-
ctx.send(ws, { type: MSG.ERROR, payload: { error:
|
|
4043
|
+
ctx.send(ws, { type: MSG.ERROR, payload: { error: RECORD_NOT_FOUND } });
|
|
3942
4044
|
return;
|
|
3943
4045
|
}
|
|
3944
4046
|
const permCtx = ctx.getPermissionContext();
|
|
@@ -3971,7 +4073,10 @@ function handleYjsJoin(ctx, ws, attachment, payload) {
|
|
|
3971
4073
|
writeVarUint8Array(encoder2, fullState);
|
|
3972
4074
|
ctx.sendBinary(ws, toUint8Array(encoder2));
|
|
3973
4075
|
}
|
|
3974
|
-
ctx.send(ws, {
|
|
4076
|
+
ctx.send(ws, {
|
|
4077
|
+
type: MSG.YJS_JOIN,
|
|
4078
|
+
payload: { collection, recordId, fieldName, canWrite: hasWriteAccess }
|
|
4079
|
+
});
|
|
3975
4080
|
}
|
|
3976
4081
|
function handleYjsLeave(ctx, ws, attachment, payload) {
|
|
3977
4082
|
const { collection, recordId, fieldName } = payload;
|
|
@@ -4040,7 +4145,14 @@ function handleYjsBinaryMessage(ctx, ws, attachment, data) {
|
|
|
4040
4145
|
if (!syncSchema) return;
|
|
4041
4146
|
const record = getRecord(ctx.sql, collection, recordId, syncSchema);
|
|
4042
4147
|
if (!record) return;
|
|
4043
|
-
if (!canUpdate(
|
|
4148
|
+
if (!canUpdate(
|
|
4149
|
+
syncSchema,
|
|
4150
|
+
attachment.role,
|
|
4151
|
+
{ ...record, recordId },
|
|
4152
|
+
attachment.userId,
|
|
4153
|
+
ctx.getPermissionContext()
|
|
4154
|
+
))
|
|
4155
|
+
return;
|
|
4044
4156
|
}
|
|
4045
4157
|
const update = readVarUint8Array(decoder);
|
|
4046
4158
|
Y2.applyUpdate(doc, update, "client");
|
|
@@ -4114,6 +4226,19 @@ function handleYjsDisconnect(ctx, ws, attachment) {
|
|
|
4114
4226
|
}
|
|
4115
4227
|
|
|
4116
4228
|
// src/server/handlers/subscriptions.ts
|
|
4229
|
+
function resolveCollection(ctx, collection) {
|
|
4230
|
+
const isSystem = SYSTEM_COLLECTIONS.has(collection);
|
|
4231
|
+
const schema = ctx.schemaRegistry.get(collection);
|
|
4232
|
+
if (!schema && !isSystem) {
|
|
4233
|
+
const known = ctx.schemaRegistry.names().sort().join(", ") || "(none)";
|
|
4234
|
+
const room = ctx.scopeId ? `Room '${ctx.scopeId}'` : "This room";
|
|
4235
|
+
return {
|
|
4236
|
+
ok: false,
|
|
4237
|
+
error: `Schema not registered for collection: ${collection} \u2014 ${room} serves only: ${known}. Records are sharded by scope, so address the room that owns '${collection}', or add it to the schemas this room is constructed with.`
|
|
4238
|
+
};
|
|
4239
|
+
}
|
|
4240
|
+
return { ok: true, schema, isSystem };
|
|
4241
|
+
}
|
|
4117
4242
|
function handleSubscribe(ctx, ws, attachment, payload) {
|
|
4118
4243
|
const { subscriptionId, query } = payload;
|
|
4119
4244
|
const records = executeQuery(ctx, query, attachment.userId, attachment.role);
|
|
@@ -4121,16 +4246,23 @@ function handleSubscribe(ctx, ws, attachment, payload) {
|
|
|
4121
4246
|
}
|
|
4122
4247
|
function handleUnsubscribe(_ctx, _ws, _attachment, _payload) {
|
|
4123
4248
|
}
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
const
|
|
4127
|
-
if (!
|
|
4128
|
-
|
|
4129
|
-
}
|
|
4130
|
-
if (!schema) {
|
|
4249
|
+
var MAX_FILTERED_SCAN_ROWS = 5e3;
|
|
4250
|
+
function executeQuery(ctx, query, userId, userRole, skipUserRbac = false, scanState) {
|
|
4251
|
+
const resolved = resolveCollection(ctx, query.collection);
|
|
4252
|
+
if (!resolved.ok) return [];
|
|
4253
|
+
if (!resolved.schema) {
|
|
4131
4254
|
return executeSystemQuery(ctx, query);
|
|
4132
4255
|
}
|
|
4133
|
-
return executeTableQuery(
|
|
4256
|
+
return executeTableQuery(
|
|
4257
|
+
ctx,
|
|
4258
|
+
query,
|
|
4259
|
+
resolved.schema,
|
|
4260
|
+
userId,
|
|
4261
|
+
userRole,
|
|
4262
|
+
resolved.isSystem,
|
|
4263
|
+
skipUserRbac,
|
|
4264
|
+
scanState
|
|
4265
|
+
);
|
|
4134
4266
|
}
|
|
4135
4267
|
function executeSystemQuery(ctx, query) {
|
|
4136
4268
|
const tbl = collectionTableName(query.collection);
|
|
@@ -4166,7 +4298,7 @@ function preloadUserTeamIds(ctx, userId) {
|
|
|
4166
4298
|
);
|
|
4167
4299
|
return cursor.toArray().map((r) => r.col_teamid);
|
|
4168
4300
|
}
|
|
4169
|
-
function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipUserRbac = false) {
|
|
4301
|
+
function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipUserRbac = false, scanState) {
|
|
4170
4302
|
const columns = (schema.columns ?? []).map(resolveColumn);
|
|
4171
4303
|
const perms = getRolePermissions(schema, userRole);
|
|
4172
4304
|
let sql = buildTableSelect(query.collection, columns);
|
|
@@ -4174,16 +4306,27 @@ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipU
|
|
|
4174
4306
|
const whereClauses = [];
|
|
4175
4307
|
let skipPerRecordCheck = skipUserRbac;
|
|
4176
4308
|
if (!skipUserRbac && schema.teamField && perms.read === "team") {
|
|
4177
|
-
const filterCol = schema.teamField === "_rowId" ? "_row_id" : columns.find((c) => c.name === schema.teamField)?.id
|
|
4178
|
-
const teamIds = preloadUserTeamIds(ctx, userId);
|
|
4179
|
-
if (
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4309
|
+
const filterCol = schema.teamField === "_rowId" ? "_row_id" : columns.find((c) => c.name === schema.teamField)?.id;
|
|
4310
|
+
const teamIds = filterCol ? preloadUserTeamIds(ctx, userId) : null;
|
|
4311
|
+
if (filterCol) {
|
|
4312
|
+
if (teamIds && teamIds.length > 0) {
|
|
4313
|
+
const placeholders = teamIds.map(() => "?").join(", ");
|
|
4314
|
+
whereClauses.push(`("${filterCol}" IN (${placeholders}) OR _created_by = ?)`);
|
|
4315
|
+
params.push(...teamIds, userId);
|
|
4316
|
+
} else {
|
|
4317
|
+
whereClauses.push(`_created_by = ?`);
|
|
4318
|
+
params.push(userId);
|
|
4319
|
+
}
|
|
4320
|
+
skipPerRecordCheck = true;
|
|
4321
|
+
}
|
|
4322
|
+
} else if (!skipUserRbac && perms.read === "own") {
|
|
4323
|
+
const ownerCol = schema.ownerField ? columns.find((c) => c.name === schema.ownerField)?.id : "_created_by";
|
|
4324
|
+
if (ownerCol) {
|
|
4325
|
+
whereClauses.push(`"${ownerCol}" = ?`);
|
|
4185
4326
|
params.push(userId);
|
|
4327
|
+
skipPerRecordCheck = true;
|
|
4186
4328
|
}
|
|
4329
|
+
} else if (!skipUserRbac && perms.read === true) {
|
|
4187
4330
|
skipPerRecordCheck = true;
|
|
4188
4331
|
}
|
|
4189
4332
|
if (query.where) {
|
|
@@ -4221,27 +4364,44 @@ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipU
|
|
|
4221
4364
|
} else {
|
|
4222
4365
|
sql += ` ORDER BY _created_at DESC`;
|
|
4223
4366
|
}
|
|
4224
|
-
|
|
4225
|
-
sql
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4367
|
+
const readPage = (limitClause, limitParams) => {
|
|
4368
|
+
const cursor = ctx.sql.exec(sql + limitClause, ...params, ...limitParams);
|
|
4369
|
+
const rows = cursor.toArray();
|
|
4370
|
+
const records = [];
|
|
4371
|
+
for (const row of rows) {
|
|
4372
|
+
const r = row;
|
|
4373
|
+
const record = {
|
|
4374
|
+
recordId: r._row_id,
|
|
4375
|
+
data: rowToData(r, columns),
|
|
4376
|
+
createdBy: r._created_by,
|
|
4377
|
+
createdAt: r._created_at,
|
|
4378
|
+
updatedAt: r._updated_at
|
|
4379
|
+
};
|
|
4380
|
+
if (isSystem || skipPerRecordCheck || canRead(schema, userRole, { data: record.data, createdBy: record.createdBy, recordId: record.recordId }, userId, ctx.getPermissionContext())) {
|
|
4381
|
+
records.push(record);
|
|
4382
|
+
}
|
|
4383
|
+
}
|
|
4384
|
+
return { records, scanned: rows.length };
|
|
4385
|
+
};
|
|
4386
|
+
if (!query.limit) return readPage("", []).records;
|
|
4387
|
+
const rowFiltered = !isSystem && !skipPerRecordCheck;
|
|
4388
|
+
if (!rowFiltered) return readPage(" LIMIT ?", [query.limit]).records;
|
|
4229
4389
|
const results = [];
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
const
|
|
4233
|
-
const record
|
|
4234
|
-
recordId: r._row_id,
|
|
4235
|
-
data,
|
|
4236
|
-
createdBy: r._created_by,
|
|
4237
|
-
createdAt: r._created_at,
|
|
4238
|
-
updatedAt: r._updated_at
|
|
4239
|
-
};
|
|
4240
|
-
if (isSystem || skipPerRecordCheck || canRead(schema, userRole, { data: record.data, createdBy: record.createdBy, recordId: record.recordId }, userId, ctx.getPermissionContext())) {
|
|
4390
|
+
const batch = Math.max(query.limit, 200);
|
|
4391
|
+
for (let offset = 0; ; offset += batch) {
|
|
4392
|
+
const { records, scanned } = readPage(" LIMIT ? OFFSET ?", [batch, offset]);
|
|
4393
|
+
for (const record of records) {
|
|
4241
4394
|
results.push(record);
|
|
4395
|
+
if (results.length >= query.limit) return results;
|
|
4396
|
+
}
|
|
4397
|
+
if (scanned < batch) return results;
|
|
4398
|
+
if (offset + batch >= MAX_FILTERED_SCAN_ROWS) {
|
|
4399
|
+
if (scanState && readPage(" LIMIT ? OFFSET ?", [1, offset + batch]).scanned > 0) {
|
|
4400
|
+
scanState.capped = true;
|
|
4401
|
+
}
|
|
4402
|
+
return results;
|
|
4242
4403
|
}
|
|
4243
4404
|
}
|
|
4244
|
-
return results;
|
|
4245
4405
|
}
|
|
4246
4406
|
function recordMatchesSubscription(record, collection, query, userId, userRole, schema, ctx) {
|
|
4247
4407
|
if (query.collection !== collection) return false;
|
|
@@ -4438,30 +4598,56 @@ async function registerUser(sql, userId, name, email, imageUrl, isAdmin, default
|
|
|
4438
4598
|
);
|
|
4439
4599
|
return { id: userId, email, name, imageUrl, role, createdAt: now, lastSeenAt: now };
|
|
4440
4600
|
}
|
|
4601
|
+
function rosterFor(ctx, schema, records, publicRoster, attachment) {
|
|
4602
|
+
if (isAnonymousUserId(attachment.userId)) return [];
|
|
4603
|
+
if (attachment.role === "admin") {
|
|
4604
|
+
const permissionContext = ctx.getPermissionContext();
|
|
4605
|
+
return records.filter((record) => canRead(schema, "admin", record, attachment.userId, permissionContext)).map((record) => ({ id: record.recordId, ...record.data }));
|
|
4606
|
+
}
|
|
4607
|
+
if (!getRolePermissions(schema, attachment.role).read) return [];
|
|
4608
|
+
if (schema.roster === "read-policy") {
|
|
4609
|
+
const permissionContext = ctx.getPermissionContext();
|
|
4610
|
+
const readable = new Set(
|
|
4611
|
+
records.filter((record) => canRead(schema, attachment.role, record, attachment.userId, permissionContext)).map((record) => record.recordId)
|
|
4612
|
+
);
|
|
4613
|
+
return publicRoster.filter((user) => readable.has(user.id));
|
|
4614
|
+
}
|
|
4615
|
+
return publicRoster;
|
|
4616
|
+
}
|
|
4617
|
+
function publicIdentities(records) {
|
|
4618
|
+
return records.map(({ recordId, data }) => ({
|
|
4619
|
+
id: recordId,
|
|
4620
|
+
name: data.name,
|
|
4621
|
+
...data.imageUrl ? { imageUrl: data.imageUrl } : {},
|
|
4622
|
+
role: data.role
|
|
4623
|
+
}));
|
|
4624
|
+
}
|
|
4441
4625
|
function handleUserList(ctx, ws, attachment) {
|
|
4442
4626
|
const schema = ctx.schemaRegistry.get("users");
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
attachment.userId,
|
|
4450
|
-
permissionContext
|
|
4451
|
-
)
|
|
4452
|
-
);
|
|
4453
|
-
const users = visibleRecords.map((record) => {
|
|
4454
|
-
if (attachment.role === "admin") return { id: record.recordId, ...record.data };
|
|
4455
|
-
const { name, imageUrl, role } = record.data;
|
|
4456
|
-
return {
|
|
4457
|
-
id: record.recordId,
|
|
4458
|
-
name,
|
|
4459
|
-
...imageUrl ? { imageUrl } : {},
|
|
4460
|
-
role
|
|
4461
|
-
};
|
|
4462
|
-
});
|
|
4627
|
+
if (!schema) {
|
|
4628
|
+
ctx.send(ws, { type: MSG.USER_LIST, payload: { users: [] } });
|
|
4629
|
+
return;
|
|
4630
|
+
}
|
|
4631
|
+
const records = getAllUserRecords(ctx.sql, ctx.schemaRegistry);
|
|
4632
|
+
const users = rosterFor(ctx, schema, records, publicIdentities(records), attachment);
|
|
4463
4633
|
ctx.send(ws, { type: MSG.USER_LIST, payload: { users } });
|
|
4464
4634
|
}
|
|
4635
|
+
function broadcastUserList(ctx, except) {
|
|
4636
|
+
const schema = ctx.schemaRegistry.get("users");
|
|
4637
|
+
if (!schema) return;
|
|
4638
|
+
const skip = except instanceof Set ? except : new Set(except ? [except] : []);
|
|
4639
|
+
const records = getAllUserRecords(ctx.sql, ctx.schemaRegistry);
|
|
4640
|
+
const publicRoster = publicIdentities(records);
|
|
4641
|
+
for (const other of ctx.state.getWebSockets()) {
|
|
4642
|
+
if (skip.has(other)) continue;
|
|
4643
|
+
const attachment = other.deserializeAttachment();
|
|
4644
|
+
if (!attachment) continue;
|
|
4645
|
+
ctx.send(other, {
|
|
4646
|
+
type: MSG.USER_LIST,
|
|
4647
|
+
payload: { users: rosterFor(ctx, schema, records, publicRoster, attachment) }
|
|
4648
|
+
});
|
|
4649
|
+
}
|
|
4650
|
+
}
|
|
4465
4651
|
function handleUserUpdate(ctx, _ws, attachment, payload) {
|
|
4466
4652
|
const existing = getUserRecord(ctx.sql, attachment.userId, ctx.schemaRegistry);
|
|
4467
4653
|
if (!existing) return;
|
|
@@ -4470,6 +4656,8 @@ function handleUserUpdate(ctx, _ws, attachment, payload) {
|
|
|
4470
4656
|
if (payload.email) data.email = payload.email;
|
|
4471
4657
|
if (payload.imageUrl !== void 0) data.imageUrl = payload.imageUrl || void 0;
|
|
4472
4658
|
putRecord(ctx, "users", attachment.userId, data, attachment.userId, "admin", true, true);
|
|
4659
|
+
const rosterChanged = data.name !== void 0 && data.name !== existing.data.name || data.imageUrl !== void 0 && data.imageUrl !== existing.data.imageUrl;
|
|
4660
|
+
if (rosterChanged) broadcastUserList(ctx);
|
|
4473
4661
|
}
|
|
4474
4662
|
async function handleSetRole(ctx, ws, attachment, payload) {
|
|
4475
4663
|
if (attachment.role !== "admin") {
|
|
@@ -4488,20 +4676,17 @@ async function handleSetRole(ctx, ws, attachment, payload) {
|
|
|
4488
4676
|
now,
|
|
4489
4677
|
payload.userId
|
|
4490
4678
|
);
|
|
4679
|
+
const closing = /* @__PURE__ */ new Set();
|
|
4491
4680
|
for (const otherWs of ctx.state.getWebSockets()) {
|
|
4492
4681
|
const otherAttachment = otherWs.deserializeAttachment();
|
|
4493
|
-
if (
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
}
|
|
4499
|
-
continue;
|
|
4500
|
-
}
|
|
4501
|
-
if (otherAttachment.role === "admin") {
|
|
4502
|
-
handleUserList(ctx, otherWs, otherAttachment);
|
|
4682
|
+
if (otherAttachment?.userId !== payload.userId) continue;
|
|
4683
|
+
closing.add(otherWs);
|
|
4684
|
+
try {
|
|
4685
|
+
otherWs.close(1008, "role-changed");
|
|
4686
|
+
} catch {
|
|
4503
4687
|
}
|
|
4504
4688
|
}
|
|
4689
|
+
broadcastUserList(ctx, closing);
|
|
4505
4690
|
}
|
|
4506
4691
|
|
|
4507
4692
|
// src/server/handlers/tools-api.ts
|
|
@@ -4523,7 +4708,10 @@ async function handleToolsRequest(ctx, request, path) {
|
|
|
4523
4708
|
const toolName = path.replace("tools/describe/", "");
|
|
4524
4709
|
const tool2 = BUILT_IN_TOOLS.find((t) => t.name === toolName);
|
|
4525
4710
|
if (!tool2) {
|
|
4526
|
-
return Response.json(
|
|
4711
|
+
return Response.json(
|
|
4712
|
+
{ error: `Tool not found: ${toolName}` },
|
|
4713
|
+
{ status: 404, headers: CORS_HEADERS3 }
|
|
4714
|
+
);
|
|
4527
4715
|
}
|
|
4528
4716
|
return Response.json({ tool: tool2 }, { headers: CORS_HEADERS3 });
|
|
4529
4717
|
}
|
|
@@ -4568,20 +4756,23 @@ async function handleToolExecute(ctx, request) {
|
|
|
4568
4756
|
const recordCtx = {
|
|
4569
4757
|
sql: ctx.sql,
|
|
4570
4758
|
schemaRegistry: ctx.schemaRegistry,
|
|
4759
|
+
scopeId: ctx.scopeId,
|
|
4571
4760
|
state: ctx.state,
|
|
4572
4761
|
getPermissionContext: ctx.getPermissionContext,
|
|
4573
4762
|
send: ctx.send
|
|
4574
4763
|
};
|
|
4575
|
-
const result = await
|
|
4764
|
+
const result = await executeTool(recordCtx, tool2, params, userId, userRole, isAppAction);
|
|
4576
4765
|
const status = result.success ? 200 : result.error?.includes("not found") ? 404 : 400;
|
|
4577
4766
|
return Response.json(result, { status, headers: CORS_HEADERS3 });
|
|
4578
4767
|
}
|
|
4579
|
-
async function
|
|
4768
|
+
async function executeTool(ctx, toolName, params, userId, userRole, skipUserRbac) {
|
|
4580
4769
|
switch (toolName) {
|
|
4581
4770
|
// ---- Records ----
|
|
4582
4771
|
case "records.query": {
|
|
4583
4772
|
const collection = params.collection;
|
|
4584
4773
|
if (!collection) return { success: false, error: "Missing required param: collection" };
|
|
4774
|
+
const resolved = resolveCollection(ctx, collection);
|
|
4775
|
+
if (!resolved.ok) return { success: false, error: resolved.error };
|
|
4585
4776
|
const query = {
|
|
4586
4777
|
collection,
|
|
4587
4778
|
where: params.where,
|
|
@@ -4623,6 +4814,15 @@ async function executeTool2(ctx, toolName, params, userId, userRole, skipUserRba
|
|
|
4623
4814
|
if (!recordId) return { success: false, error: "Missing required param: recordId" };
|
|
4624
4815
|
return deleteRecord(ctx, collection, recordId, userId, userRole, skipUserRbac);
|
|
4625
4816
|
}
|
|
4817
|
+
// Bounded batch delete — one subrequest per page instead of one per row.
|
|
4818
|
+
// Same authorization posture as `records.delete`; `deleteWhere` itself
|
|
4819
|
+
// validates `where` (every key must name a real field) and `limit`, so no
|
|
4820
|
+
// dispatch path can reach a collection-wide truncation.
|
|
4821
|
+
case "records.deleteWhere": {
|
|
4822
|
+
const collection = params.collection;
|
|
4823
|
+
if (!collection) return { success: false, error: "Missing required param: collection" };
|
|
4824
|
+
return deleteWhere(ctx, collection, params.where, params.limit, userId, userRole, skipUserRbac);
|
|
4825
|
+
}
|
|
4626
4826
|
// ---- Users ----
|
|
4627
4827
|
// Bootstrap or refresh the `users` row for a given userId. Same path
|
|
4628
4828
|
// the SDK uses on WS connect via registerUser, exposed for server
|
|
@@ -4691,10 +4891,13 @@ async function executeTool2(ctx, toolName, params, userId, userRole, skipUserRba
|
|
|
4691
4891
|
}
|
|
4692
4892
|
function checkYjsPermission(ctx, collection, recordId, userId, userRole, action) {
|
|
4693
4893
|
if (SYSTEM_COLLECTIONS.has(collection)) return null;
|
|
4694
|
-
const
|
|
4695
|
-
if (!
|
|
4894
|
+
const resolution = resolveCollection(ctx, collection);
|
|
4895
|
+
if (!resolution.ok) return { success: false, error: resolution.error };
|
|
4896
|
+
const schema = resolution.schema;
|
|
4897
|
+
if (!schema)
|
|
4898
|
+
return { success: false, error: `Schema not registered for collection: ${collection}` };
|
|
4696
4899
|
const record = getRecord(ctx.sql, collection, recordId, schema);
|
|
4697
|
-
if (!record) return { success: false, error:
|
|
4900
|
+
if (!record) return { success: false, error: `${RECORD_NOT_FOUND}: ${collection}/${recordId}` };
|
|
4698
4901
|
const recordWithId = { ...record, recordId };
|
|
4699
4902
|
const permCtx = ctx.getPermissionContext();
|
|
4700
4903
|
if (action === "read" && !canRead(schema, userRole, recordWithId, userId, permCtx)) {
|
|
@@ -4727,9 +4930,7 @@ function withYjsDoc(ctx, docKey, operation) {
|
|
|
4727
4930
|
function executeYjsTool(ctx, toolName, params, userId, userRole) {
|
|
4728
4931
|
switch (toolName) {
|
|
4729
4932
|
case "yjs.list": {
|
|
4730
|
-
const rows = ctx.sql.exec(
|
|
4731
|
-
`SELECT doc_key, updated_at FROM yjs_docs ORDER BY updated_at DESC`
|
|
4732
|
-
).toArray();
|
|
4933
|
+
const rows = ctx.sql.exec(`SELECT doc_key, updated_at FROM yjs_docs ORDER BY updated_at DESC`).toArray();
|
|
4733
4934
|
const docs = rows.map((row) => {
|
|
4734
4935
|
const firstColon = row.doc_key.indexOf(":");
|
|
4735
4936
|
const lastColon = row.doc_key.lastIndexOf(":");
|
|
@@ -5169,12 +5370,13 @@ var RecordRoom = class extends BaseRoom {
|
|
|
5169
5370
|
const userName = user.userName;
|
|
5170
5371
|
const userEmail = user.userEmail;
|
|
5171
5372
|
const userImageUrl = user.userImageUrl;
|
|
5172
|
-
const isAuthenticated = !userId
|
|
5373
|
+
const isAuthenticated = !isAnonymousUserId(userId);
|
|
5173
5374
|
let attachment;
|
|
5174
5375
|
if (isAuthenticated) {
|
|
5175
5376
|
const isOwner2 = this.ownerUserId != null && userId === this.ownerUserId;
|
|
5176
5377
|
const regStart = Date.now();
|
|
5177
5378
|
const defaultRole = this.schemaRegistry.get("users")?.defaultRole ?? "member";
|
|
5379
|
+
const before = getUser(this.sql, userId, this.schemaRegistry);
|
|
5178
5380
|
const registeredUser = await registerUser(
|
|
5179
5381
|
this.sql,
|
|
5180
5382
|
userId,
|
|
@@ -5186,6 +5388,9 @@ var RecordRoom = class extends BaseRoom {
|
|
|
5186
5388
|
this.schemaRegistry
|
|
5187
5389
|
);
|
|
5188
5390
|
const regMs = Date.now() - regStart;
|
|
5391
|
+
if (!before || before.name !== registeredUser.name || before.imageUrl !== registeredUser.imageUrl || before.role !== registeredUser.role) {
|
|
5392
|
+
broadcastUserList(this.createUserContext(), ws);
|
|
5393
|
+
}
|
|
5189
5394
|
attachment = {
|
|
5190
5395
|
userId: registeredUser.id,
|
|
5191
5396
|
userName,
|
|
@@ -5352,6 +5557,10 @@ var RecordRoom = class extends BaseRoom {
|
|
|
5352
5557
|
sql: this.sql,
|
|
5353
5558
|
state: this.state,
|
|
5354
5559
|
schemaRegistry: this.schemaRegistry,
|
|
5560
|
+
// Every record room stub is addressed with `idFromName(scopeId)`, so the
|
|
5561
|
+
// DO's own name is the scope. Handlers use it to name the room when a
|
|
5562
|
+
// caller asks it for a collection that lives in a different scope.
|
|
5563
|
+
scopeId: this.state.id.name,
|
|
5355
5564
|
getPermissionContext: () => this.getPermissionContext(),
|
|
5356
5565
|
send: (ws, msg) => this.send(ws, msg),
|
|
5357
5566
|
sendBinary: (ws, data) => this.sendBinaryHelper(ws, data),
|
|
@@ -8576,6 +8785,7 @@ export {
|
|
|
8576
8785
|
CanvasRoom,
|
|
8577
8786
|
CronRoom,
|
|
8578
8787
|
DEFAULT_CONTEXT_CONFIG,
|
|
8788
|
+
DEFAULT_DELETE_WHERE_LIMIT,
|
|
8579
8789
|
DEFAULT_DO_MANIFEST,
|
|
8580
8790
|
DEFAULT_QUERY_LIMIT,
|
|
8581
8791
|
DEPLOYED_APPS_DEFAULTS,
|
|
@@ -8586,6 +8796,7 @@ export {
|
|
|
8586
8796
|
GLOBAL_DO_TYPE_NAMES,
|
|
8587
8797
|
JobRoom,
|
|
8588
8798
|
MAX_APP_FILE_BYTES,
|
|
8799
|
+
MAX_DELETE_WHERE_LIMIT,
|
|
8589
8800
|
MAX_DEPLOY_ASSET_FILE_BYTES,
|
|
8590
8801
|
MAX_UPLOAD_PARTS,
|
|
8591
8802
|
MAX_UPLOAD_REQUEST_BYTES,
|
|
@@ -8601,7 +8812,9 @@ export {
|
|
|
8601
8812
|
RefundError,
|
|
8602
8813
|
RegistryClientError,
|
|
8603
8814
|
STRICT_APP_ID_RE,
|
|
8815
|
+
SYSTEM_ASSIGNED_COLUMNS,
|
|
8604
8816
|
SYSTEM_COLLECTION_SCHEMAS,
|
|
8817
|
+
SYSTEM_MAINTAINED_COLUMNS,
|
|
8605
8818
|
SYSTEM_MANAGED_COLUMNS,
|
|
8606
8819
|
SchemaRegistry,
|
|
8607
8820
|
SubscriptionAuthError,
|
|
@@ -8620,6 +8833,7 @@ export {
|
|
|
8620
8833
|
authWorkerFetch,
|
|
8621
8834
|
bindingManifestFromOutputConfig,
|
|
8622
8835
|
broadcastChange,
|
|
8836
|
+
broadcastUserList,
|
|
8623
8837
|
broadcastYjsUpdate,
|
|
8624
8838
|
buildCronContext,
|
|
8625
8839
|
buildTableSelect,
|
|
@@ -8681,6 +8895,8 @@ export {
|
|
|
8681
8895
|
isTestAccountEmail,
|
|
8682
8896
|
isTestAccountTier,
|
|
8683
8897
|
lintSchema,
|
|
8898
|
+
lintSchemaSet,
|
|
8899
|
+
lintSchemas,
|
|
8684
8900
|
loadMessages,
|
|
8685
8901
|
makeDefaultSummarizer,
|
|
8686
8902
|
matchOrigin,
|