deepspace 0.6.2 → 0.8.0

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +22 -8
  3. package/dist/cli.js +10348 -5025
  4. package/dist/cli.js.map +1 -1
  5. package/dist/feature-installer.js +638 -0
  6. package/dist/feature-installer.js.map +1 -0
  7. package/dist/index.d.ts +229 -116
  8. package/dist/index.js +767 -788
  9. package/dist/index.js.map +1 -1
  10. package/dist/server.d.ts +403 -167
  11. package/dist/server.js +498 -367
  12. package/dist/server.js.map +1 -1
  13. package/dist/testing.d.ts +1 -1
  14. package/dist/testing.js +3 -3
  15. package/dist/testing.js.map +1 -1
  16. package/dist/worker.d.ts +541 -159
  17. package/dist/worker.js +611 -367
  18. package/dist/worker.js.map +1 -1
  19. package/features/ai-chat/feature.json +2 -0
  20. package/features/ai-chat/src/AiChatPage.tsx +2 -3
  21. package/features/ai-chat/src/ChatPanel.messages.tsx +344 -0
  22. package/features/ai-chat/src/ChatPanel.stream.ts +397 -0
  23. package/features/ai-chat/src/ChatPanel.tsx +159 -825
  24. package/features/ai-chat/src/__tests__/ChatPanel.stream.test.ts +163 -0
  25. package/features/canvas/src/CanvasListPage.tsx +1 -1
  26. package/features/docs/feature.json +4 -8
  27. package/features/docs/src/DocsEditorPage.tsx +33 -326
  28. package/features/docs/src/DocsListPage.tsx +33 -29
  29. package/features/docs/src/InviteDialog.tsx +11 -40
  30. package/features/docs/src/__tests__/docs-presence-access.test.ts +71 -0
  31. package/features/docs/src/docs-library-types.ts +28 -3
  32. package/features/docs/src/docs-ui.css +1 -4
  33. package/features/docs/src/use-docs-presence-access.ts +330 -0
  34. package/features/file-attachments/feature.json +2 -2
  35. package/features/file-attachments/src/AttachmentPreview.tsx +428 -0
  36. package/features/file-attachments/src/AttachmentsPage.tsx +100 -462
  37. package/features/file-attachments/src/useFilePreview.ts +54 -68
  38. package/features/file-manager/src/FileManagerPage.tsx +1 -1
  39. package/features/integration-test/src/integration.spec.ts +1 -1
  40. package/features/items/feature.json +1 -5
  41. package/features/items/src/ItemsPage.tsx +207 -135
  42. package/features/landing/feature.json +3 -0
  43. package/features/landing/src/LandingPage.tsx +4 -18
  44. package/features/leaderboard/feature.json +1 -5
  45. package/features/leaderboard/src/LeaderboardPage.tsx +333 -198
  46. package/features/messaging/feature.json +0 -4
  47. package/features/messaging/src/ChatMultiPage.tsx +1 -3
  48. package/features/messaging/src/ChatPage.tsx +1 -3
  49. package/features/messaging/src/components/chat/MessageActionSheet.tsx +40 -16
  50. package/features/search-bar/feature.json +8 -3
  51. package/features/search-bar/src/SearchOverlay.tsx +108 -252
  52. package/features/search-bar/src/__tests__/search-model.test.ts +40 -0
  53. package/features/search-bar/src/search-model.ts +223 -0
  54. package/features/tasks/src/TasksPage.tsx +3 -3
  55. package/features/topbar/src/Topbar.tsx +0 -17
  56. package/package.json +1 -2
  57. package/features/docs/src/docs-favorites.ts +0 -18
  58. package/features/docs/src/use-yjs-room-with-awareness.ts +0 -289
  59. package/features/file-attachments/src/attachments-constants.ts +0 -9
  60. package/features/items/src/items-constants.ts +0 -10
  61. package/features/leaderboard/src/leaderboard-constants.ts +0 -21
  62. package/features/messaging/src/components/chat/ReactionPicker.tsx +0 -2
  63. package/scripts/add-feature.cjs +0 -807
package/dist/worker.js CHANGED
@@ -59,7 +59,7 @@ var BaseRoom = class {
59
59
  // ==========================================================================
60
60
  // WebSocket Connection
61
61
  // ==========================================================================
62
- async handleWebSocketUpgrade(request, url) {
62
+ async handleWebSocketUpgrade(_request, url) {
63
63
  const userId = url.searchParams.get("userId") || void 0;
64
64
  const userName = url.searchParams.get("userName") || "Anonymous";
65
65
  const userEmail = url.searchParams.get("userEmail") || "";
@@ -111,7 +111,7 @@ var BaseRoom = class {
111
111
  }
112
112
  }
113
113
  }
114
- async webSocketError(ws, error) {
114
+ async webSocketError(_ws, error) {
115
115
  console.error(`[${this.constructor.name}] webSocketError:`, error);
116
116
  }
117
117
  async alarm() {
@@ -955,6 +955,14 @@ var Awareness = class {
955
955
  this.emit({ added: [], updated: [], removed });
956
956
  }
957
957
  }
958
+ /** Release all state and listeners owned by this awareness instance. */
959
+ destroy() {
960
+ this.setLocalState(null);
961
+ this.states.clear();
962
+ this.meta.clear();
963
+ this.updateListeners.clear();
964
+ this.changeListeners.clear();
965
+ }
958
966
  };
959
967
  function encodeAwarenessMessage(awareness, clientIds) {
960
968
  const encoder = createEncoder();
@@ -1397,6 +1405,11 @@ function parseYjsDocKey(docKey) {
1397
1405
  fieldName: docKey.slice(lastColon + 1)
1398
1406
  };
1399
1407
  }
1408
+ function hasYjsSubscription(attachment, target) {
1409
+ return attachment.yjsSubscriptions.some(
1410
+ (subscription) => subscription.collection === target.collection && subscription.recordId === target.recordId && subscription.fieldName === target.fieldName
1411
+ );
1412
+ }
1400
1413
  var SYSTEM_COLLECTIONS = /* @__PURE__ */ new Set([
1401
1414
  "canvas-settings"
1402
1415
  ]);
@@ -1412,7 +1425,7 @@ var SYSTEM_COLLECTION_SCHEMAS = [
1412
1425
  function getYjsDocKey(collection, recordId, fieldName) {
1413
1426
  return `${collection}:${recordId}:${fieldName}`;
1414
1427
  }
1415
- async function getOrCreateYjsDoc(ctx, docKey) {
1428
+ function getOrCreateYjsDoc(ctx, docKey) {
1416
1429
  let doc = ctx.yjsDocs.get(docKey);
1417
1430
  if (doc) return doc;
1418
1431
  doc = new Y2.Doc();
@@ -1434,6 +1447,20 @@ async function getOrCreateYjsDoc(ctx, docKey) {
1434
1447
  ctx.yjsDocs.set(docKey, doc);
1435
1448
  return doc;
1436
1449
  }
1450
+ function unloadYjsDocIfUnused(ctx, docKey, excludeWs = null) {
1451
+ const target = parseYjsDocKey(docKey);
1452
+ if (!target) return false;
1453
+ for (const ws of ctx.state.getWebSockets()) {
1454
+ if (ws === excludeWs) continue;
1455
+ const attachment = ws.deserializeAttachment();
1456
+ if (attachment && hasYjsSubscription(attachment, target)) return false;
1457
+ }
1458
+ const doc = ctx.yjsDocs.get(docKey);
1459
+ if (!doc) return false;
1460
+ ctx.yjsDocs.delete(docKey);
1461
+ doc.destroy();
1462
+ return true;
1463
+ }
1437
1464
  function saveYjsDoc(sql, docKey, doc) {
1438
1465
  const state = Y2.encodeStateAsUpdate(doc);
1439
1466
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -1447,7 +1474,7 @@ function saveYjsDoc(sql, docKey, doc) {
1447
1474
  now
1448
1475
  );
1449
1476
  }
1450
- async function handleYjsJoin(ctx, ws, attachment, payload) {
1477
+ function handleYjsJoin(ctx, ws, attachment, payload) {
1451
1478
  const { collection, recordId, fieldName } = payload;
1452
1479
  const isSystem = SYSTEM_COLLECTIONS.has(collection);
1453
1480
  const schema = ctx.schemaRegistry.get(collection);
@@ -1486,10 +1513,8 @@ async function handleYjsJoin(ctx, ws, attachment, payload) {
1486
1513
  hasWriteAccess = canUpdate(schema, attachment.role, recordWithId, attachment.userId, permCtx);
1487
1514
  }
1488
1515
  const docKey = getYjsDocKey(collection, recordId, fieldName);
1489
- const doc = await getOrCreateYjsDoc(ctx, docKey);
1490
- if (!attachment.yjsSubscriptions.some(
1491
- (s) => s.collection === collection && s.recordId === recordId && s.fieldName === fieldName
1492
- )) {
1516
+ const doc = getOrCreateYjsDoc(ctx, docKey);
1517
+ if (!hasYjsSubscription(attachment, payload)) {
1493
1518
  attachment.yjsSubscriptions.push({ collection, recordId, fieldName });
1494
1519
  ws.serializeAttachment(attachment);
1495
1520
  }
@@ -1511,14 +1536,15 @@ async function handleYjsJoin(ctx, ws, attachment, payload) {
1511
1536
  }
1512
1537
  ctx.send(ws, { type: MSG.YJS_JOIN, payload: { collection, recordId, fieldName, canWrite: hasWriteAccess } });
1513
1538
  }
1514
- function handleYjsLeave(ws, attachment, payload) {
1539
+ function handleYjsLeave(ctx, ws, attachment, payload) {
1515
1540
  const { collection, recordId, fieldName } = payload;
1516
1541
  attachment.yjsSubscriptions = attachment.yjsSubscriptions.filter(
1517
1542
  (s) => !(s.collection === collection && s.recordId === recordId && s.fieldName === fieldName)
1518
1543
  );
1519
1544
  ws.serializeAttachment(attachment);
1545
+ unloadYjsDocIfUnused(ctx, getYjsDocKey(collection, recordId, fieldName), ws);
1520
1546
  }
1521
- async function handleYjsBinaryMessage(ctx, ws, attachment, data) {
1547
+ function handleYjsBinaryMessage(ctx, ws, attachment, data) {
1522
1548
  const decoder = createDecoder(data);
1523
1549
  const messageType = readVarUint(decoder);
1524
1550
  if (messageType === MSG_YJS_AWARENESS) {
@@ -1527,11 +1553,7 @@ async function handleYjsBinaryMessage(ctx, ws, attachment, data) {
1527
1553
  const payload = readVarUint8Array(decoder);
1528
1554
  const parsed2 = parseYjsDocKey(docKey2);
1529
1555
  if (!parsed2) return;
1530
- const { collection: collection2, recordId: recordId2, fieldName: fieldName2 } = parsed2;
1531
- const isSubscribed2 = attachment.yjsSubscriptions.some(
1532
- (s) => s.collection === collection2 && s.recordId === recordId2 && s.fieldName === fieldName2
1533
- );
1534
- if (!isSubscribed2) return;
1556
+ if (!hasYjsSubscription(attachment, parsed2)) return;
1535
1557
  if (attachment.awarenessClientId == null) {
1536
1558
  try {
1537
1559
  const pd = createDecoder(payload);
@@ -1551,12 +1573,9 @@ async function handleYjsBinaryMessage(ctx, ws, attachment, data) {
1551
1573
  const docKey = new TextDecoder().decode(docKeyBytes);
1552
1574
  const parsed = parseYjsDocKey(docKey);
1553
1575
  if (!parsed) return;
1554
- const { collection, recordId, fieldName } = parsed;
1555
- const isSubscribed = attachment.yjsSubscriptions.some(
1556
- (s) => s.collection === collection && s.recordId === recordId && s.fieldName === fieldName
1557
- );
1558
- if (!isSubscribed) return;
1559
- const doc = await getOrCreateYjsDoc(ctx, docKey);
1576
+ const { collection, recordId } = parsed;
1577
+ if (!hasYjsSubscription(attachment, parsed)) return;
1578
+ const doc = getOrCreateYjsDoc(ctx, docKey);
1560
1579
  const syncType = readVarUint(decoder);
1561
1580
  switch (syncType) {
1562
1581
  case MSG_SYNC_STEP1: {
@@ -1581,7 +1600,6 @@ async function handleYjsBinaryMessage(ctx, ws, attachment, data) {
1581
1600
  }
1582
1601
  const update = readVarUint8Array(decoder);
1583
1602
  Y2.applyUpdate(doc, update, "client");
1584
- saveYjsDoc(ctx.sql, docKey, doc);
1585
1603
  broadcastYjsUpdate(ctx, docKey, update, ws);
1586
1604
  break;
1587
1605
  }
@@ -1590,7 +1608,6 @@ async function handleYjsBinaryMessage(ctx, ws, attachment, data) {
1590
1608
  function broadcastYjsUpdate(ctx, docKey, update, excludeWs) {
1591
1609
  const parsed = parseYjsDocKey(docKey);
1592
1610
  if (!parsed) return;
1593
- const { collection, recordId, fieldName } = parsed;
1594
1611
  const encoder = createEncoder();
1595
1612
  writeVarUint(encoder, MSG_YJS_SYNC);
1596
1613
  writeVarUint8Array(encoder, new TextEncoder().encode(docKey));
@@ -1601,10 +1618,7 @@ function broadcastYjsUpdate(ctx, docKey, update, excludeWs) {
1601
1618
  if (ws === excludeWs) continue;
1602
1619
  const attachment = ws.deserializeAttachment();
1603
1620
  if (!attachment) continue;
1604
- const isSubscribed = attachment.yjsSubscriptions.some(
1605
- (s) => s.collection === collection && s.recordId === recordId && s.fieldName === fieldName
1606
- );
1607
- if (isSubscribed) {
1621
+ if (hasYjsSubscription(attachment, parsed)) {
1608
1622
  ctx.sendBinary(ws, message);
1609
1623
  }
1610
1624
  }
@@ -1612,7 +1626,6 @@ function broadcastYjsUpdate(ctx, docKey, update, excludeWs) {
1612
1626
  function broadcastAwareness(ctx, docKey, payload, excludeWs) {
1613
1627
  const parsed = parseYjsDocKey(docKey);
1614
1628
  if (!parsed) return;
1615
- const { collection, recordId, fieldName } = parsed;
1616
1629
  const encoder = createEncoder();
1617
1630
  writeVarUint(encoder, MSG_YJS_AWARENESS);
1618
1631
  writeVarUint8Array(encoder, new TextEncoder().encode(docKey));
@@ -1622,10 +1635,7 @@ function broadcastAwareness(ctx, docKey, payload, excludeWs) {
1622
1635
  if (ws === excludeWs) continue;
1623
1636
  const attachment = ws.deserializeAttachment();
1624
1637
  if (!attachment) continue;
1625
- const isSubscribed = attachment.yjsSubscriptions.some(
1626
- (s) => s.collection === collection && s.recordId === recordId && s.fieldName === fieldName
1627
- );
1628
- if (isSubscribed) {
1638
+ if (hasYjsSubscription(attachment, parsed)) {
1629
1639
  ctx.sendBinary(ws, message);
1630
1640
  }
1631
1641
  }
@@ -1644,6 +1654,17 @@ function broadcastAwarenessRemoval(ctx, attachment) {
1644
1654
  broadcastAwareness(ctx, docKey, removalPayload, null);
1645
1655
  }
1646
1656
  }
1657
+ function handleYjsDisconnect(ctx, ws, attachment) {
1658
+ broadcastAwarenessRemoval(ctx, attachment);
1659
+ const docKeys = new Set(
1660
+ attachment.yjsSubscriptions.map(
1661
+ (subscription) => getYjsDocKey(subscription.collection, subscription.recordId, subscription.fieldName)
1662
+ )
1663
+ );
1664
+ for (const docKey of docKeys) {
1665
+ unloadYjsDocIfUnused(ctx, docKey, ws);
1666
+ }
1667
+ }
1647
1668
 
1648
1669
  // src/server/handlers/subscriptions.ts
1649
1670
  function handleSubscribe(ctx, ws, attachment, payload) {
@@ -1977,7 +1998,7 @@ function handleUserList(ctx, ws, _attachment) {
1977
1998
  }));
1978
1999
  ctx.send(ws, { type: MSG.USER_LIST, payload: { users } });
1979
2000
  }
1980
- function handleUserUpdate(ctx, ws, attachment, payload) {
2001
+ function handleUserUpdate(ctx, _ws, attachment, payload) {
1981
2002
  const existing = getUserRecord(ctx.sql, attachment.userId, ctx.schemaRegistry);
1982
2003
  if (!existing) return;
1983
2004
  const data = { lastSeenAt: (/* @__PURE__ */ new Date()).toISOString() };
@@ -2107,72 +2128,6 @@ var BUILT_IN_TOOLS = [
2107
2128
  description: "List all users in this room",
2108
2129
  params: {}
2109
2130
  },
2110
- // Storage tools (legacy key-value)
2111
- {
2112
- name: "storage.list",
2113
- description: "List all keys in a storage scope",
2114
- params: {
2115
- scope: { type: "string", description: "Storage scope: global, files, user, userFiles", required: true },
2116
- prefix: { type: "string", description: "Optional key prefix filter", required: false },
2117
- userId: { type: "string", description: "User ID (required for user/userFiles scopes)", required: false }
2118
- }
2119
- },
2120
- {
2121
- name: "storage.read",
2122
- description: "Read one or more keys from storage",
2123
- params: {
2124
- scope: { type: "string", description: "Storage scope: global, files, user, userFiles", required: true },
2125
- keys: { type: "array", description: "Array of keys to read (or single key string)", required: true },
2126
- userId: { type: "string", description: "User ID (required for user/userFiles scopes)", required: false }
2127
- }
2128
- },
2129
- {
2130
- name: "storage.write",
2131
- description: "Write one or more key-value pairs to storage (batch supported)",
2132
- params: {
2133
- scope: { type: "string", description: "Storage scope: global, files, user, userFiles", required: true },
2134
- data: { type: "object", description: "Object with key-value pairs to write", required: true },
2135
- userId: { type: "string", description: "User ID (required for user/userFiles scopes)", required: false }
2136
- }
2137
- },
2138
- {
2139
- name: "storage.delete",
2140
- description: "Delete one or more keys from storage",
2141
- params: {
2142
- scope: { type: "string", description: "Storage scope: global, files, user, userFiles", required: true },
2143
- keys: { type: "array", description: "Array of keys to delete", required: true },
2144
- userId: { type: "string", description: "User ID (required for user/userFiles scopes)", required: false }
2145
- }
2146
- },
2147
- // Backup tools
2148
- {
2149
- name: "backup.create",
2150
- description: "Create a backup of the current Yjs document state to R2",
2151
- params: {
2152
- description: { type: "string", description: "Optional description for the backup", required: false }
2153
- }
2154
- },
2155
- {
2156
- name: "backup.list",
2157
- description: "List all available backups",
2158
- params: {
2159
- limit: { type: "number", description: "Maximum number of backups to return (default: 50)", required: false }
2160
- }
2161
- },
2162
- {
2163
- name: "backup.restore",
2164
- description: "Restore the Yjs document from a backup",
2165
- params: {
2166
- backupId: { type: "string", description: "The backup ID to restore from", required: true }
2167
- }
2168
- },
2169
- {
2170
- name: "backup.delete",
2171
- description: "Delete a specific backup",
2172
- params: {
2173
- backupId: { type: "string", description: "The backup ID to delete", required: true }
2174
- }
2175
- },
2176
2131
  // Yjs tools (collaborative document access)
2177
2132
  {
2178
2133
  name: "yjs.list",
@@ -2256,7 +2211,7 @@ async function handleToolExecute(ctx, request) {
2256
2211
  }
2257
2212
  }
2258
2213
  if (tool2.startsWith("yjs.")) {
2259
- const result2 = await executeYjsTool(ctx, tool2, params, userId, userRole);
2214
+ const result2 = executeYjsTool(ctx, tool2, params, userId, userRole);
2260
2215
  const status2 = result2.success ? 200 : result2.error?.includes("not found") ? 404 : 400;
2261
2216
  return Response.json(result2, { status: status2, headers: CORS_HEADERS });
2262
2217
  }
@@ -2411,7 +2366,15 @@ function buildYjsCtx(ctx) {
2411
2366
  sendBinary: ctx.sendBinary
2412
2367
  };
2413
2368
  }
2414
- async function executeYjsTool(ctx, toolName, params, userId, userRole) {
2369
+ function withYjsDoc(ctx, docKey, operation) {
2370
+ const doc = getOrCreateYjsDoc(ctx, docKey);
2371
+ try {
2372
+ return operation(doc);
2373
+ } finally {
2374
+ unloadYjsDocIfUnused(ctx, docKey);
2375
+ }
2376
+ }
2377
+ function executeYjsTool(ctx, toolName, params, userId, userRole) {
2415
2378
  switch (toolName) {
2416
2379
  case "yjs.list": {
2417
2380
  const rows = ctx.sql.exec(
@@ -2439,20 +2402,21 @@ async function executeYjsTool(ctx, toolName, params, userId, userRole) {
2439
2402
  if (permError) return permError;
2440
2403
  const docKey = getYjsDocKey(collection, recordId, fieldName);
2441
2404
  const yjsCtx = buildYjsCtx(ctx);
2442
- const doc = await getOrCreateYjsDoc(yjsCtx, docKey);
2443
- const ytext = doc.getText(fieldName);
2444
- const text = ytext.toString();
2445
- const sharedTypes = {};
2446
- for (const [key, type] of doc.share) {
2447
- if (type instanceof Y3.Text) {
2448
- sharedTypes[key || "(default)"] = `Y.Text (${type.length} chars)`;
2449
- } else if (type instanceof Y3.Map) {
2450
- sharedTypes[key] = `Y.Map (${type.size} entries)`;
2451
- } else if (type instanceof Y3.Array) {
2452
- sharedTypes[key] = `Y.Array (${type.length} items)`;
2405
+ return withYjsDoc(yjsCtx, docKey, (doc) => {
2406
+ const ytext = doc.getText(fieldName);
2407
+ const text = ytext.toString();
2408
+ const sharedTypes = {};
2409
+ for (const [key, type] of doc.share) {
2410
+ if (type instanceof Y3.Text) {
2411
+ sharedTypes[key || "(default)"] = `Y.Text (${type.length} chars)`;
2412
+ } else if (type instanceof Y3.Map) {
2413
+ sharedTypes[key] = `Y.Map (${type.size} entries)`;
2414
+ } else if (type instanceof Y3.Array) {
2415
+ sharedTypes[key] = `Y.Array (${type.length} items)`;
2416
+ }
2453
2417
  }
2454
- }
2455
- return { success: true, data: { docKey, text, sharedTypes } };
2418
+ return { success: true, data: { docKey, text, sharedTypes } };
2419
+ });
2456
2420
  }
2457
2421
  case "yjs.setText": {
2458
2422
  const { collection, recordId, fieldName, text } = params;
@@ -2466,22 +2430,26 @@ async function executeYjsTool(ctx, toolName, params, userId, userRole) {
2466
2430
  if (permError) return permError;
2467
2431
  const docKey = getYjsDocKey(collection, recordId, fieldName);
2468
2432
  const yjsCtx = buildYjsCtx(ctx);
2469
- const doc = await getOrCreateYjsDoc(yjsCtx, docKey);
2470
- const ytext = doc.getText(fieldName);
2471
- let capturedUpdate = null;
2472
- const updateHandler = (update) => {
2473
- capturedUpdate = update;
2474
- };
2475
- doc.on("update", updateHandler);
2476
- doc.transact(() => {
2477
- ytext.delete(0, ytext.length);
2478
- ytext.insert(0, text);
2433
+ return withYjsDoc(yjsCtx, docKey, (doc) => {
2434
+ const ytext = doc.getText(fieldName);
2435
+ let capturedUpdate = null;
2436
+ const updateHandler = (update) => {
2437
+ capturedUpdate = update;
2438
+ };
2439
+ doc.on("update", updateHandler);
2440
+ try {
2441
+ doc.transact(() => {
2442
+ ytext.delete(0, ytext.length);
2443
+ ytext.insert(0, text);
2444
+ });
2445
+ } finally {
2446
+ doc.off("update", updateHandler);
2447
+ }
2448
+ if (capturedUpdate) {
2449
+ broadcastYjsUpdate(yjsCtx, docKey, capturedUpdate, null);
2450
+ }
2451
+ return { success: true, data: { docKey, length: text.length } };
2479
2452
  });
2480
- doc.off("update", updateHandler);
2481
- if (capturedUpdate) {
2482
- broadcastYjsUpdate(yjsCtx, docKey, capturedUpdate, null);
2483
- }
2484
- return { success: true, data: { docKey, length: text.length } };
2485
2453
  }
2486
2454
  default:
2487
2455
  return { success: false, error: `Unknown Yjs tool: ${toolName}` };
@@ -2724,8 +2692,6 @@ var RecordRoom = class extends BaseRoom {
2724
2692
  initPromise = null;
2725
2693
  /** Yjs docs loaded in memory (key: collection:recordId:fieldName) */
2726
2694
  yjsDocs = /* @__PURE__ */ new Map();
2727
- /** Next Yjs client ID counter */
2728
- nextYjsClientId = 1;
2729
2695
  /** Owner user ID — gets admin role automatically */
2730
2696
  ownerUserId;
2731
2697
  /** True until the first fetch() completes — detects hibernation wake-up */
@@ -2742,7 +2708,7 @@ var RecordRoom = class extends BaseRoom {
2742
2708
  * The HTTP debug API (`/api/debug/*`) runs arbitrary SQL and role changes
2743
2709
  * with no auth of its own, so it is gated here at the DO's single ingress.
2744
2710
  * Off unless a deployment opts in with `ALLOW_DEBUG_ROUTES=true`
2745
- * (`deepspace dev`/`test` set it automatically). Deployments holding shared
2711
+ * (`deepspace dev start`/`test run` set it automatically). Deployments holding shared
2746
2712
  * data override this to always return false.
2747
2713
  */
2748
2714
  get debugRoutesEnabled() {
@@ -2877,13 +2843,17 @@ var RecordRoom = class extends BaseRoom {
2877
2843
  this.sql.exec(`DROP TABLE IF EXISTS records`);
2878
2844
  return;
2879
2845
  }
2880
- let migrated = 0;
2881
- let skipped = 0;
2846
+ const prepared = [];
2847
+ const blocked = {
2848
+ missingSchema: 0,
2849
+ invalidData: 0,
2850
+ targetConflict: 0
2851
+ };
2882
2852
  for (const row of rows) {
2883
2853
  const r = row;
2884
2854
  const schema = this.schemaRegistry.get(r.collection);
2885
2855
  if (!schema || !schema.columns) {
2886
- skipped++;
2856
+ blocked.missingSchema++;
2887
2857
  continue;
2888
2858
  }
2889
2859
  const tbl = collectionTableName(r.collection);
@@ -2893,14 +2863,18 @@ var RecordRoom = class extends BaseRoom {
2893
2863
  }
2894
2864
  const existing = this.sql.exec(`SELECT 1 FROM "${tbl}" WHERE _row_id = ?`, r.record_id).toArray();
2895
2865
  if (existing.length > 0) {
2896
- skipped++;
2866
+ blocked.targetConflict++;
2897
2867
  continue;
2898
2868
  }
2899
2869
  let data;
2900
2870
  try {
2901
2871
  data = JSON.parse(r.data);
2902
2872
  } catch {
2903
- skipped++;
2873
+ blocked.invalidData++;
2874
+ continue;
2875
+ }
2876
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
2877
+ blocked.invalidData++;
2904
2878
  continue;
2905
2879
  }
2906
2880
  const columns = schema.columns.map(resolveColumn);
@@ -2921,20 +2895,32 @@ var RecordRoom = class extends BaseRoom {
2921
2895
  r.updated_at,
2922
2896
  ...colIds.map((c) => colValues[c])
2923
2897
  ];
2924
- this.sql.exec(
2925
- `INSERT INTO "${tbl}" (${allCols.join(", ")}) VALUES (${placeholders})`,
2926
- ...params
2927
- );
2928
- migrated++;
2898
+ prepared.push({
2899
+ sql: `INSERT INTO "${tbl}" (${allCols.join(", ")}) VALUES (${placeholders})`,
2900
+ params
2901
+ });
2929
2902
  }
2930
- if (migrated > 0 || skipped > 0) {
2931
- console.log(
2932
- `[RecordRoom] Migrated ${migrated} records from document-mode, ${skipped} skipped`
2903
+ const blockedCount = blocked.missingSchema + blocked.invalidData + blocked.targetConflict;
2904
+ if (blockedCount > 0) {
2905
+ console.error(
2906
+ `[RecordRoom] Legacy records migration blocked; no records migrated and the records table was retained for retry (${blockedCount} blocked: ${blocked.missingSchema} missing schema, ${blocked.invalidData} invalid data, ${blocked.targetConflict} target conflict)`
2933
2907
  );
2908
+ return;
2934
2909
  }
2935
- this.sql.exec(`DROP TABLE IF EXISTS records`);
2910
+ this.state.storage.transactionSync(() => {
2911
+ for (const record of prepared) {
2912
+ this.sql.exec(record.sql, ...record.params);
2913
+ }
2914
+ this.sql.exec(`DROP TABLE IF EXISTS records`);
2915
+ });
2916
+ console.log(
2917
+ `[RecordRoom] Migrated ${prepared.length} records from document-mode; legacy records table removed`
2918
+ );
2936
2919
  } catch (error) {
2937
- console.error(`[RecordRoom] Records table migration error:`, error);
2920
+ console.error(
2921
+ `[RecordRoom] Legacy records migration failed; no records migrated and the records table was retained for retry:`,
2922
+ error
2923
+ );
2938
2924
  }
2939
2925
  }
2940
2926
  // ============================================================================
@@ -2987,8 +2973,7 @@ var RecordRoom = class extends BaseRoom {
2987
2973
  userImageUrl,
2988
2974
  role: registeredUser.role,
2989
2975
  subscriptions: [],
2990
- yjsSubscriptions: [],
2991
- yjsClientId: this.nextYjsClientId++
2976
+ yjsSubscriptions: []
2992
2977
  };
2993
2978
  this.send(ws, { type: MSG.USER_INFO, payload: registeredUser });
2994
2979
  const totalMs = Date.now() - timing.fetchStart;
@@ -3003,8 +2988,7 @@ var RecordRoom = class extends BaseRoom {
3003
2988
  userEmail: "",
3004
2989
  role: ROLE_ANONYMOUS,
3005
2990
  subscriptions: [],
3006
- yjsSubscriptions: [],
3007
- yjsClientId: this.nextYjsClientId++
2991
+ yjsSubscriptions: []
3008
2992
  };
3009
2993
  this.send(ws, {
3010
2994
  type: MSG.USER_INFO,
@@ -3073,12 +3057,12 @@ var RecordRoom = class extends BaseRoom {
3073
3057
  try {
3074
3058
  const attachment = ws.deserializeAttachment();
3075
3059
  if (attachment) {
3076
- broadcastAwarenessRemoval(this.createYjsContext(), attachment);
3060
+ handleYjsDisconnect(this.createYjsContext(), ws, attachment);
3077
3061
  }
3078
3062
  } catch {
3079
3063
  }
3080
3064
  }
3081
- async webSocketError(ws, error) {
3065
+ async webSocketError(_ws, error) {
3082
3066
  console.error(`[RecordRoom] webSocketError:`, error);
3083
3067
  }
3084
3068
  // ============================================================================
@@ -3121,7 +3105,7 @@ var RecordRoom = class extends BaseRoom {
3121
3105
  await handleYjsJoin(yjsCtx, ws, attachment, payload);
3122
3106
  break;
3123
3107
  case MSG.YJS_LEAVE:
3124
- handleYjsLeave(ws, attachment, payload);
3108
+ handleYjsLeave(yjsCtx, ws, attachment, payload);
3125
3109
  break;
3126
3110
  case MSG.LIST_SCHEMAS:
3127
3111
  this.handleListSchemas(ws);
@@ -3280,7 +3264,7 @@ var YjsRoom = class extends BaseRoom {
3280
3264
  }
3281
3265
  onMessage(_ws, _user, _message) {
3282
3266
  }
3283
- onBinaryMessage(ws, user, data) {
3267
+ onBinaryMessage(ws, _user, data) {
3284
3268
  const bytes = new Uint8Array(data);
3285
3269
  const decoder = createDecoder(bytes);
3286
3270
  const messageType = readVarUint(decoder);
@@ -3573,7 +3557,7 @@ var GameRoom = class extends BaseRoom {
3573
3557
  this.sendTo(ws, { type: MSG.ERROR, payload: { error: `Unknown game message type: ${type}` } });
3574
3558
  }
3575
3559
  }
3576
- onDisconnect(ws, user) {
3560
+ onDisconnect(_ws, user) {
3577
3561
  const player = this.players.get(user.userId);
3578
3562
  if (player) {
3579
3563
  this.players.delete(user.userId);
@@ -3881,7 +3865,7 @@ var CanvasRoom = class extends BaseRoom {
3881
3865
  this.sendTo(ws, { type: MSG.ERROR, payload: { error: `Unknown canvas message type: ${type}` } });
3882
3866
  }
3883
3867
  }
3884
- onDisconnect(ws, user) {
3868
+ onDisconnect(_ws, user) {
3885
3869
  this.viewports.delete(user.userId);
3886
3870
  this.undoStacks.delete(user.userId);
3887
3871
  this.redoStacks.delete(user.userId);
@@ -4075,6 +4059,140 @@ var PresenceRoom = class extends BaseRoom {
4075
4059
  }
4076
4060
  };
4077
4061
 
4062
+ // src/server/rooms/cron-schedule.ts
4063
+ var TASK_NAME_RE = /^[a-z0-9-]+$/;
4064
+ function validateTask(task) {
4065
+ if (!task.name || typeof task.name !== "string") {
4066
+ throw new Error("CronTask.name is required");
4067
+ }
4068
+ if (task.name.length > 64 || !TASK_NAME_RE.test(task.name)) {
4069
+ throw new Error(
4070
+ `CronTask.name "${task.name}" must be lowercase alphanumeric with hyphens, 1-64 chars`
4071
+ );
4072
+ }
4073
+ const hasInterval = task.intervalMinutes != null;
4074
+ const hasSchedule = task.schedule != null;
4075
+ const hasTimezone = task.timezone != null;
4076
+ if (hasInterval && (hasSchedule || hasTimezone)) {
4077
+ throw new Error(`CronTask "${task.name}" cannot mix intervalMinutes with schedule/timezone`);
4078
+ }
4079
+ if (!hasInterval && !(hasSchedule && hasTimezone)) {
4080
+ throw new Error(
4081
+ `CronTask "${task.name}" must declare either intervalMinutes or schedule+timezone`
4082
+ );
4083
+ }
4084
+ if (hasInterval && (!Number.isInteger(task.intervalMinutes) || task.intervalMinutes < 1 || task.intervalMinutes > 10080)) {
4085
+ throw new Error(`CronTask "${task.name}" intervalMinutes must be an integer 1..10080`);
4086
+ }
4087
+ if (hasSchedule) {
4088
+ parseCronExpression(task.schedule);
4089
+ if (typeof task.timezone !== "string" || task.timezone.length === 0) {
4090
+ throw new Error(`CronTask "${task.name}" timezone must be a non-empty IANA string`);
4091
+ }
4092
+ try {
4093
+ new Intl.DateTimeFormat("en-US", { timeZone: task.timezone });
4094
+ } catch {
4095
+ throw new Error(
4096
+ `CronTask "${task.name}" timezone "${task.timezone}" is not a valid IANA timezone`
4097
+ );
4098
+ }
4099
+ }
4100
+ return task;
4101
+ }
4102
+ function parseCronExpression(expression) {
4103
+ const fields = expression.trim().split(/\s+/);
4104
+ if (fields.length !== 5) {
4105
+ throw new Error(
4106
+ `Cron expression must have exactly 5 fields, got ${fields.length}: "${expression}"`
4107
+ );
4108
+ }
4109
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = fields;
4110
+ return {
4111
+ minute: parseField(minute, 0, 59, "minute"),
4112
+ hour: parseField(hour, 0, 23, "hour"),
4113
+ dayOfMonth: parseField(dayOfMonth, 1, 31, "day-of-month"),
4114
+ month: parseField(month, 1, 12, "month"),
4115
+ dayOfWeek: parseField(dayOfWeek, 0, 6, "day-of-week")
4116
+ };
4117
+ }
4118
+ function parseField(field, min, max, label) {
4119
+ const result = /* @__PURE__ */ new Set();
4120
+ for (const part of field.split(",")) {
4121
+ const value = part.trim();
4122
+ if (value === "*") {
4123
+ for (let i = min; i <= max; i++) result.add(i);
4124
+ continue;
4125
+ }
4126
+ const allStep = value.match(/^\*\/(\d+)$/);
4127
+ if (allStep) {
4128
+ const step = Number.parseInt(allStep[1], 10);
4129
+ if (step <= 0) throw new Error(`Cron ${label} step must be positive: "${value}"`);
4130
+ for (let i = min; i <= max; i += step) result.add(i);
4131
+ continue;
4132
+ }
4133
+ const range = value.match(/^(\d+)-(\d+)(?:\/(\d+))?$/);
4134
+ if (range) {
4135
+ const start = Number.parseInt(range[1], 10);
4136
+ const end = Number.parseInt(range[2], 10);
4137
+ const step = range[3] ? Number.parseInt(range[3], 10) : 1;
4138
+ if (start < min || end > max || start > end || step <= 0) {
4139
+ throw new Error(`Cron ${label} range out of bounds: "${value}" (${min}..${max})`);
4140
+ }
4141
+ for (let i = start; i <= end; i += step) result.add(i);
4142
+ continue;
4143
+ }
4144
+ const literal = Number.parseInt(value, 10);
4145
+ if (Number.isNaN(literal) || literal < min || literal > max || String(literal) !== value) {
4146
+ throw new Error(`Cron ${label} value invalid: "${value}" (${min}..${max})`);
4147
+ }
4148
+ result.add(literal);
4149
+ }
4150
+ if (result.size === 0) {
4151
+ throw new Error(`Cron ${label} field "${field}" produced no valid values`);
4152
+ }
4153
+ return result;
4154
+ }
4155
+ function nextCronFire(expression, timezone, from) {
4156
+ const parsed = parseCronExpression(expression);
4157
+ const start = Math.floor(from.getTime() / 6e4) * 6e4 + 6e4;
4158
+ const limit = start + 366 * 24 * 60 * 6e4;
4159
+ for (let timestamp = start; timestamp < limit; timestamp += 6e4) {
4160
+ const candidate = new Date(timestamp);
4161
+ if (cronMatches(parsed, timezone, candidate)) return candidate;
4162
+ }
4163
+ return null;
4164
+ }
4165
+ function cronMatches(parsed, timezone, date) {
4166
+ const formatter = new Intl.DateTimeFormat("en-US", {
4167
+ timeZone: timezone,
4168
+ year: "numeric",
4169
+ month: "numeric",
4170
+ day: "numeric",
4171
+ hour: "numeric",
4172
+ minute: "numeric",
4173
+ weekday: "short",
4174
+ hour12: false
4175
+ });
4176
+ const parts = Object.fromEntries(
4177
+ formatter.formatToParts(date).map((part) => [part.type, part.value])
4178
+ );
4179
+ const minute = Number.parseInt(parts.minute, 10);
4180
+ const hour = Number.parseInt(parts.hour, 10) % 24;
4181
+ const dayOfMonth = Number.parseInt(parts.day, 10);
4182
+ const month = Number.parseInt(parts.month, 10);
4183
+ const weekday = {
4184
+ Sun: 0,
4185
+ Mon: 1,
4186
+ Tue: 2,
4187
+ Wed: 3,
4188
+ Thu: 4,
4189
+ Fri: 5,
4190
+ Sat: 6
4191
+ };
4192
+ const dayOfWeek = weekday[parts.weekday] ?? 0;
4193
+ return parsed.minute.has(minute) && parsed.hour.has(hour) && parsed.dayOfMonth.has(dayOfMonth) && parsed.month.has(month) && parsed.dayOfWeek.has(dayOfWeek);
4194
+ }
4195
+
4078
4196
  // src/server/rooms/cron-room.ts
4079
4197
  var CRON_WRITE_TYPES = /* @__PURE__ */ new Set([
4080
4198
  MSG.CRON_TRIGGER,
@@ -4115,10 +4233,7 @@ var CronRoom = class extends BaseRoom {
4115
4233
  )
4116
4234
  `);
4117
4235
  for (const task of this.tasks) {
4118
- const existing = this.sql.exec(
4119
- `SELECT name FROM cron_tasks WHERE name = ?`,
4120
- task.name
4121
- ).toArray();
4236
+ const existing = this.sql.exec(`SELECT name FROM cron_tasks WHERE name = ?`, task.name).toArray();
4122
4237
  if (existing.length === 0) {
4123
4238
  this.sql.exec(
4124
4239
  `INSERT INTO cron_tasks (name, interval_minutes, schedule, timezone, paused)
@@ -4189,7 +4304,12 @@ var CronRoom = class extends BaseRoom {
4189
4304
  this.sendTo(ws, { type: MSG.ERROR, payload: { error: "Missing taskName" } });
4190
4305
  return;
4191
4306
  }
4192
- await this.executeTask(taskName);
4307
+ if (!await this.executeTask(taskName)) {
4308
+ this.sendTo(ws, {
4309
+ type: MSG.ERROR,
4310
+ payload: { error: `Unknown cron task: ${taskName}` }
4311
+ });
4312
+ }
4193
4313
  break;
4194
4314
  }
4195
4315
  case MSG.CRON_PAUSE: {
@@ -4221,7 +4341,10 @@ var CronRoom = class extends BaseRoom {
4221
4341
  break;
4222
4342
  }
4223
4343
  default:
4224
- this.sendTo(ws, { type: MSG.ERROR, payload: { error: `Unknown cron message type: ${type}` } });
4344
+ this.sendTo(ws, {
4345
+ type: MSG.ERROR,
4346
+ payload: { error: `Unknown cron message type: ${type}` }
4347
+ });
4225
4348
  }
4226
4349
  }
4227
4350
  async onAlarm() {
@@ -4241,6 +4364,8 @@ var CronRoom = class extends BaseRoom {
4241
4364
  // Task Execution
4242
4365
  // ==========================================================================
4243
4366
  async executeTask(taskName) {
4367
+ const taskRow = this.sql.exec(`SELECT interval_minutes, schedule, timezone FROM cron_tasks WHERE name = ?`, taskName).toArray()[0];
4368
+ if (!taskRow) return false;
4244
4369
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
4245
4370
  const start = Date.now();
4246
4371
  let success = true;
@@ -4265,21 +4390,18 @@ var CronRoom = class extends BaseRoom {
4265
4390
  error ?? null,
4266
4391
  completedAt
4267
4392
  );
4268
- const taskRow = this.sql.exec(
4269
- `SELECT interval_minutes, schedule, timezone FROM cron_tasks WHERE name = ?`,
4393
+ const nextRunAt = computeNextRunAt(taskRow, new Date(completedAt));
4394
+ this.sql.exec(
4395
+ `UPDATE cron_tasks SET last_run_at = ?, next_run_at = ? WHERE name = ?`,
4396
+ completedAt,
4397
+ nextRunAt,
4270
4398
  taskName
4271
- ).toArray()[0];
4272
- if (taskRow) {
4273
- const nextRunAt = computeNextRunAt(taskRow, new Date(completedAt));
4274
- this.sql.exec(
4275
- `UPDATE cron_tasks SET last_run_at = ?, next_run_at = ? WHERE name = ?`,
4276
- completedAt,
4277
- nextRunAt,
4278
- taskName
4279
- );
4280
- }
4281
- this.sql.exec(`DELETE FROM cron_history WHERE id NOT IN (SELECT id FROM cron_history ORDER BY id DESC LIMIT 500)`);
4399
+ );
4400
+ this.sql.exec(
4401
+ `DELETE FROM cron_history WHERE id NOT IN (SELECT id FROM cron_history ORDER BY id DESC LIMIT 500)`
4402
+ );
4282
4403
  this.broadcastStatus();
4404
+ return true;
4283
4405
  }
4284
4406
  // ==========================================================================
4285
4407
  // Scheduling
@@ -4328,10 +4450,7 @@ var CronRoom = class extends BaseRoom {
4328
4450
  });
4329
4451
  }
4330
4452
  getRecentHistory(limit) {
4331
- return this.sql.exec(
4332
- `SELECT * FROM cron_history ORDER BY id DESC LIMIT ?`,
4333
- limit
4334
- ).toArray().map((row) => {
4453
+ return this.sql.exec(`SELECT * FROM cron_history ORDER BY id DESC LIMIT ?`, limit).toArray().map((row) => {
4335
4454
  const r = row;
4336
4455
  return {
4337
4456
  taskName: r.task_name,
@@ -4353,100 +4472,6 @@ var CronRoom = class extends BaseRoom {
4353
4472
  });
4354
4473
  }
4355
4474
  };
4356
- var TASK_NAME_RE = /^[a-z0-9-]+$/;
4357
- function validateTask(task) {
4358
- if (!task.name || typeof task.name !== "string") {
4359
- throw new Error(`CronTask.name is required`);
4360
- }
4361
- if (task.name.length > 64 || !TASK_NAME_RE.test(task.name)) {
4362
- throw new Error(
4363
- `CronTask.name "${task.name}" must be lowercase alphanumeric with hyphens, 1-64 chars`
4364
- );
4365
- }
4366
- const hasInterval = task.intervalMinutes != null;
4367
- const hasSchedule = task.schedule != null;
4368
- const hasTimezone = task.timezone != null;
4369
- if (hasInterval && (hasSchedule || hasTimezone)) {
4370
- throw new Error(
4371
- `CronTask "${task.name}" cannot mix intervalMinutes with schedule/timezone`
4372
- );
4373
- }
4374
- if (!hasInterval && !(hasSchedule && hasTimezone)) {
4375
- throw new Error(
4376
- `CronTask "${task.name}" must declare either intervalMinutes or schedule+timezone`
4377
- );
4378
- }
4379
- if (hasInterval) {
4380
- if (!Number.isInteger(task.intervalMinutes) || task.intervalMinutes < 1 || task.intervalMinutes > 10080) {
4381
- throw new Error(
4382
- `CronTask "${task.name}" intervalMinutes must be an integer 1..10080`
4383
- );
4384
- }
4385
- }
4386
- if (hasSchedule) {
4387
- parseCronExpression(task.schedule);
4388
- if (typeof task.timezone !== "string" || task.timezone.length === 0) {
4389
- throw new Error(`CronTask "${task.name}" timezone must be a non-empty IANA string`);
4390
- }
4391
- try {
4392
- new Intl.DateTimeFormat("en-US", { timeZone: task.timezone });
4393
- } catch {
4394
- throw new Error(`CronTask "${task.name}" timezone "${task.timezone}" is not a valid IANA timezone`);
4395
- }
4396
- }
4397
- return task;
4398
- }
4399
- function parseCronExpression(expression) {
4400
- const fields = expression.trim().split(/\s+/);
4401
- if (fields.length !== 5) {
4402
- throw new Error(`Cron expression must have exactly 5 fields, got ${fields.length}: "${expression}"`);
4403
- }
4404
- const [cMin, cHour, cDom, cMonth, cDow] = fields;
4405
- return {
4406
- minute: parseField(cMin, 0, 59, "minute"),
4407
- hour: parseField(cHour, 0, 23, "hour"),
4408
- dayOfMonth: parseField(cDom, 1, 31, "day-of-month"),
4409
- month: parseField(cMonth, 1, 12, "month"),
4410
- dayOfWeek: parseField(cDow, 0, 6, "day-of-week")
4411
- };
4412
- }
4413
- function parseField(field, min, max, label) {
4414
- const result = /* @__PURE__ */ new Set();
4415
- for (const part of field.split(",")) {
4416
- const trimmed = part.trim();
4417
- if (trimmed === "*") {
4418
- for (let i = min; i <= max; i++) result.add(i);
4419
- continue;
4420
- }
4421
- const allStep = trimmed.match(/^\*\/(\d+)$/);
4422
- if (allStep) {
4423
- const step = parseInt(allStep[1], 10);
4424
- if (step <= 0) throw new Error(`Cron ${label} step must be positive: "${trimmed}"`);
4425
- for (let i = min; i <= max; i += step) result.add(i);
4426
- continue;
4427
- }
4428
- const rangeMatch = trimmed.match(/^(\d+)-(\d+)(?:\/(\d+))?$/);
4429
- if (rangeMatch) {
4430
- const start = parseInt(rangeMatch[1], 10);
4431
- const end = parseInt(rangeMatch[2], 10);
4432
- const step = rangeMatch[3] ? parseInt(rangeMatch[3], 10) : 1;
4433
- if (start < min || end > max || start > end || step <= 0) {
4434
- throw new Error(`Cron ${label} range out of bounds: "${trimmed}" (${min}..${max})`);
4435
- }
4436
- for (let i = start; i <= end; i += step) result.add(i);
4437
- continue;
4438
- }
4439
- const num = parseInt(trimmed, 10);
4440
- if (isNaN(num) || num < min || num > max || String(num) !== trimmed) {
4441
- throw new Error(`Cron ${label} value invalid: "${trimmed}" (${min}..${max})`);
4442
- }
4443
- result.add(num);
4444
- }
4445
- if (result.size === 0) {
4446
- throw new Error(`Cron ${label} field "${field}" produced no valid values`);
4447
- }
4448
- return result;
4449
- }
4450
4475
  function computeNextRunAt(task, from) {
4451
4476
  if (task.interval_minutes) {
4452
4477
  return new Date(from.getTime() + task.interval_minutes * 60 * 1e3).toISOString();
@@ -4457,40 +4482,6 @@ function computeNextRunAt(task, from) {
4457
4482
  }
4458
4483
  return null;
4459
4484
  }
4460
- function nextCronFire(expression, timezone, from) {
4461
- const parsed = parseCronExpression(expression);
4462
- const start = new Date(Math.floor(from.getTime() / 6e4) * 6e4 + 6e4);
4463
- const limit = new Date(start.getTime() + 366 * 24 * 60 * 6e4);
4464
- for (let t = start.getTime(); t < limit.getTime(); t += 6e4) {
4465
- const candidate = new Date(t);
4466
- if (cronMatches(parsed, timezone, candidate)) {
4467
- return candidate;
4468
- }
4469
- }
4470
- return null;
4471
- }
4472
- function cronMatches(parsed, timezone, date) {
4473
- const formatter = new Intl.DateTimeFormat("en-US", {
4474
- timeZone: timezone,
4475
- year: "numeric",
4476
- month: "numeric",
4477
- day: "numeric",
4478
- hour: "numeric",
4479
- minute: "numeric",
4480
- weekday: "short",
4481
- hour12: false
4482
- });
4483
- const parts = Object.fromEntries(
4484
- formatter.formatToParts(date).map((p) => [p.type, p.value])
4485
- );
4486
- const minute = parseInt(parts.minute, 10);
4487
- const hour = parseInt(parts.hour, 10) % 24;
4488
- const dayOfMonth = parseInt(parts.day, 10);
4489
- const month = parseInt(parts.month, 10);
4490
- const weekdayMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
4491
- const dayOfWeek = weekdayMap[parts.weekday] ?? 0;
4492
- return parsed.minute.has(minute) && parsed.hour.has(hour) && parsed.dayOfMonth.has(dayOfMonth) && parsed.month.has(month) && parsed.dayOfWeek.has(dayOfWeek);
4493
- }
4494
4485
 
4495
4486
  // src/server/rooms/job-room.ts
4496
4487
  var RUNNING_STALE_MS = 16 * 60 * 1e3;
@@ -4603,6 +4594,7 @@ var JobRoom = class extends BaseRoom {
4603
4594
  }
4604
4595
  async onAlarm() {
4605
4596
  this.ensureInitialized();
4597
+ this.recoverStuckRunning();
4606
4598
  await this.drainDueJobs();
4607
4599
  this.pruneExpired();
4608
4600
  this.scheduleNextAlarm();
@@ -4905,22 +4897,36 @@ var JobRoom = class extends BaseRoom {
4905
4897
  // Scheduling / recovery / pruning
4906
4898
  // ==========================================================================
4907
4899
  scheduleNextAlarm() {
4908
- const row = this.sql.exec(
4900
+ const queued = this.sql.exec(
4909
4901
  `SELECT next_run_at FROM jobs
4910
4902
  WHERE status = 'queued'
4911
4903
  ORDER BY (next_run_at IS NULL) ASC, next_run_at ASC
4912
4904
  LIMIT 1`
4913
4905
  ).toArray()[0];
4914
- if (!row) return;
4915
4906
  const now = Date.now();
4916
- const target = row.next_run_at ? Math.max(now, new Date(row.next_run_at).getTime()) : now;
4917
- this.state.storage.setAlarm(Math.max(target, now + 50));
4907
+ const targets = [];
4908
+ if (queued) {
4909
+ const queuedAt = queued.next_run_at ? new Date(queued.next_run_at).getTime() : now;
4910
+ targets.push(Number.isFinite(queuedAt) ? Math.max(now, queuedAt) : now);
4911
+ }
4912
+ const running = this.sql.exec(
4913
+ `SELECT started_at FROM jobs
4914
+ WHERE status = 'running'
4915
+ ORDER BY started_at ASC
4916
+ LIMIT 1`
4917
+ ).toArray()[0];
4918
+ if (running) {
4919
+ const startedAt = running.started_at ? new Date(running.started_at).getTime() : Number.NaN;
4920
+ targets.push(Number.isFinite(startedAt) ? Math.max(now, startedAt + RUNNING_STALE_MS) : now);
4921
+ }
4922
+ if (targets.length === 0) return;
4923
+ this.state.storage.setAlarm(Math.max(Math.min(...targets), now + 50));
4918
4924
  }
4919
4925
  recoverStuckRunning() {
4920
4926
  const cutoff = new Date(Date.now() - RUNNING_STALE_MS).toISOString();
4921
4927
  const stuck = this.sql.exec(
4922
4928
  `SELECT id, attempts, max_attempts FROM jobs
4923
- WHERE status = 'running' AND started_at < ?`,
4929
+ WHERE status = 'running' AND (started_at IS NULL OR started_at <= ?)`,
4924
4930
  cutoff
4925
4931
  ).toArray();
4926
4932
  for (const r of stuck) {
@@ -5940,11 +5946,83 @@ var RESERVED_COLLECTION_NAMES = new Set(
5940
5946
  GLOBAL_DO_TYPES.flatMap((t) => t.schemas.map((s) => s.name))
5941
5947
  );
5942
5948
 
5949
+ // src/server/utils/cors.ts
5950
+ function matchOrigin(origin, patterns) {
5951
+ if (!origin) return null;
5952
+ const matches = patterns.some((pattern) => {
5953
+ const rx = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, "[^./]+");
5954
+ return new RegExp(`^${rx}$`).test(origin);
5955
+ });
5956
+ return matches ? origin : null;
5957
+ }
5958
+ function platformCorsPatterns(env) {
5959
+ const app = env.APP_DOMAIN;
5960
+ const platform = env.PLATFORM_DOMAIN;
5961
+ return [
5962
+ .../* @__PURE__ */ new Set([`https://${platform}`, `https://*.${platform}`, `https://*.${app}`]),
5963
+ "http://localhost:*"
5964
+ ];
5965
+ }
5966
+
5967
+ // src/server/utils/plan-quotas.ts
5968
+ var DEPLOYED_APPS_DEFAULTS = {
5969
+ free: 1,
5970
+ starter: 4,
5971
+ premium: 10,
5972
+ admin: 100
5973
+ };
5974
+ function parseQuotaLimit(raw, fallback) {
5975
+ if (!raw) return fallback;
5976
+ const n = Number(raw);
5977
+ return Number.isInteger(n) && n >= 0 ? n : fallback;
5978
+ }
5979
+
5980
+ // src/server/utils/registry-client.ts
5981
+ var REGISTRY_INSTANCE = "v1:global";
5982
+ var APP_ID_RE = /^(app_[0-9A-HJKMNP-TV-Z]{26}|[a-z0-9][a-z0-9_-]{0,63})$/;
5983
+ var STRICT_APP_ID_RE = /^app_[0-9A-HJKMNP-TV-Z]{26}$/;
5984
+ var RegistryClientError = class extends Error {
5985
+ constructor(message, code, status) {
5986
+ super(message);
5987
+ this.code = code;
5988
+ this.status = status;
5989
+ this.name = "RegistryClientError";
5990
+ }
5991
+ };
5992
+ function registryStub(ns) {
5993
+ return ns.get(ns.idFromName(REGISTRY_INSTANCE));
5994
+ }
5995
+ async function callRegistry(stub, body) {
5996
+ const res = await stub.fetch("https://registry/", {
5997
+ method: "POST",
5998
+ headers: { "Content-Type": "application/json" },
5999
+ body: JSON.stringify(body)
6000
+ });
6001
+ const json = await res.json().catch(() => ({}));
6002
+ if (!res.ok) {
6003
+ throw new RegistryClientError(
6004
+ json.error ?? `Registry error (${res.status})`,
6005
+ json.code ?? "error",
6006
+ res.status
6007
+ );
6008
+ }
6009
+ return json;
6010
+ }
6011
+ function registryErrorJson(c, err) {
6012
+ if (err instanceof RegistryClientError) {
6013
+ return c.json({ error: err.message, code: err.code }, err.status);
6014
+ }
6015
+ throw err;
6016
+ }
6017
+
5943
6018
  // src/server/utils/scoped-r2-files.ts
5944
6019
  var CORS_HEADERS3 = { "Access-Control-Allow-Origin": "*" };
5945
6020
  function contentDisposition(originalName) {
5946
- const safe = originalName.replace(/[\x00-\x1f\x7f"\\]/g, "_") || "download";
5947
- const encoded = encodeURIComponent(originalName);
6021
+ const safe = originalName.replace(/[^\x20-\x7e]|["\\]/g, "_") || "download";
6022
+ const encoded = encodeURIComponent(originalName).replace(
6023
+ /[!'()*]/g,
6024
+ (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
6025
+ );
5948
6026
  return `inline; filename="${safe}"; filename*=UTF-8''${encoded}`;
5949
6027
  }
5950
6028
  function sanitizeSubpath(raw) {
@@ -5953,6 +6031,9 @@ function sanitizeSubpath(raw) {
5953
6031
  if (segments.some((s) => s === ".." || s === ".")) return null;
5954
6032
  return raw;
5955
6033
  }
6034
+ function encodeKeyPath(key) {
6035
+ return key.split("/").map(encodeURIComponent).join("/");
6036
+ }
5956
6037
  function isKeyWithinPrefix(key, prefix) {
5957
6038
  return key.startsWith(prefix);
5958
6039
  }
@@ -5973,9 +6054,15 @@ function normalizeMimeType(raw) {
5973
6054
  function createScopedR2Handler(config) {
5974
6055
  const requireAuthForMutations = config.requireAuthForMutations ?? true;
5975
6056
  return async (request, url, bucket, auth) => {
5976
- const subpathRaw = decodeURIComponent(
5977
- url.pathname.replace("/api/files", "").replace(/^\//, "")
5978
- );
6057
+ let subpathRaw;
6058
+ try {
6059
+ subpathRaw = decodeURIComponent(url.pathname.replace("/api/files", "").replace(/^\//, ""));
6060
+ } catch {
6061
+ return Response.json(
6062
+ { error: "Invalid path encoding" },
6063
+ { status: 400, headers: CORS_HEADERS3 }
6064
+ );
6065
+ }
5979
6066
  const subpath = sanitizeSubpath(subpathRaw);
5980
6067
  if (subpath === null) {
5981
6068
  return Response.json(
@@ -5983,9 +6070,10 @@ function createScopedR2Handler(config) {
5983
6070
  { status: 400, headers: CORS_HEADERS3 }
5984
6071
  );
5985
6072
  }
5986
- const isKeyedGet = subpath !== "" && request.method === "GET";
5987
- const defaultScope = isKeyedGet ? "app" : "self";
5988
- const scope = url.searchParams.get("scope") || defaultScope;
6073
+ const scope = url.searchParams.get("scope") || "self";
6074
+ if (scope !== "self" && scope !== "app") {
6075
+ return Response.json({ error: "Invalid scope" }, { status: 400, headers: CORS_HEADERS3 });
6076
+ }
5989
6077
  const result = config.resolvePrefix(scope, { userId: auth.userId, url });
5990
6078
  if (!result.prefix) {
5991
6079
  const errorMsg = result.error ?? "Invalid scope";
@@ -6000,10 +6088,10 @@ function createScopedR2Handler(config) {
6000
6088
  { status: 401, headers: CORS_HEADERS3 }
6001
6089
  );
6002
6090
  }
6003
- return handleUpload(request, url, bucket, prefix, auth.userId);
6091
+ return handleUpload(request, url, bucket, prefix, auth.userId, scope);
6004
6092
  }
6005
6093
  if (!subpath && request.method === "GET") {
6006
- return handleList(bucket, prefix, url);
6094
+ return handleList(bucket, prefix, url, scope);
6007
6095
  }
6008
6096
  if (subpath && request.method === "GET") {
6009
6097
  if (!isKeyWithinPrefix(subpath, prefix)) {
@@ -6012,7 +6100,7 @@ function createScopedR2Handler(config) {
6012
6100
  { status: 403, headers: CORS_HEADERS3 }
6013
6101
  );
6014
6102
  }
6015
- return handleDownload(bucket, subpath);
6103
+ return handleDownload(bucket, subpath, scope);
6016
6104
  }
6017
6105
  if (subpath && request.method === "DELETE") {
6018
6106
  if (requireAuthForMutations && !auth.userId) {
@@ -6033,7 +6121,7 @@ function createScopedR2Handler(config) {
6033
6121
  return Response.json({ error: "Method not allowed" }, { status: 405, headers: CORS_HEADERS3 });
6034
6122
  };
6035
6123
  }
6036
- async function handleUpload(request, url, bucket, prefix, userId) {
6124
+ async function handleUpload(request, url, bucket, prefix, userId, scope) {
6037
6125
  try {
6038
6126
  const contentType = request.headers.get("content-type") || "";
6039
6127
  let fileData;
@@ -6097,35 +6185,37 @@ async function handleUpload(request, url, bucket, prefix, userId) {
6097
6185
  uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
6098
6186
  }
6099
6187
  });
6100
- const fileUrl = `${url.origin}/api/files/${key}`;
6188
+ const fileUrl = new URL(`/api/files/${encodeKeyPath(key)}`, url.origin);
6189
+ fileUrl.searchParams.set("scope", scope);
6101
6190
  return Response.json(
6102
- { success: true, key, url: fileUrl, name: fileName },
6191
+ { success: true, key, url: fileUrl.toString(), name: fileName },
6103
6192
  { headers: CORS_HEADERS3 }
6104
6193
  );
6105
6194
  } catch (err) {
6106
6195
  const msg = err instanceof Error ? err.message : "Upload failed";
6107
6196
  console.error("[handleUpload] Error:", msg, err instanceof Error ? err.stack : "");
6108
- return Response.json(
6109
- { error: msg },
6110
- { status: 500, headers: CORS_HEADERS3 }
6111
- );
6197
+ return Response.json({ error: msg }, { status: 500, headers: CORS_HEADERS3 });
6112
6198
  }
6113
6199
  }
6114
- async function handleList(bucket, prefix, url) {
6200
+ async function handleList(bucket, prefix, url, scope) {
6115
6201
  const userPrefix = url.searchParams.get("prefix") || "";
6116
6202
  const listPrefix = `${prefix}${userPrefix}`;
6117
6203
  const limit = parseInt(url.searchParams.get("limit") || "100", 10);
6118
6204
  const listed = await bucket.list({ prefix: listPrefix, limit });
6119
- const files = listed.objects.map((obj) => ({
6120
- key: obj.key,
6121
- size: obj.size,
6122
- uploaded: obj.uploaded.toISOString(),
6123
- url: `${url.origin}/api/files/${obj.key}`,
6124
- ...obj.customMetadata
6125
- }));
6205
+ const files = listed.objects.map((obj) => {
6206
+ const fileUrl = new URL(`/api/files/${encodeKeyPath(obj.key)}`, url.origin);
6207
+ fileUrl.searchParams.set("scope", scope);
6208
+ return {
6209
+ key: obj.key,
6210
+ size: obj.size,
6211
+ uploaded: obj.uploaded.toISOString(),
6212
+ url: fileUrl.toString(),
6213
+ ...obj.customMetadata
6214
+ };
6215
+ });
6126
6216
  return Response.json({ files, truncated: listed.truncated }, { headers: CORS_HEADERS3 });
6127
6217
  }
6128
- async function handleDownload(bucket, key) {
6218
+ async function handleDownload(bucket, key, scope) {
6129
6219
  const object = await bucket.get(key);
6130
6220
  if (!object) {
6131
6221
  return Response.json({ error: "File not found" }, { status: 404, headers: CORS_HEADERS3 });
@@ -6133,7 +6223,10 @@ async function handleDownload(bucket, key) {
6133
6223
  const headers = new Headers();
6134
6224
  headers.set("Content-Type", object.httpMetadata?.contentType || "application/octet-stream");
6135
6225
  headers.set("X-Content-Type-Options", "nosniff");
6136
- headers.set("Cache-Control", "public, max-age=31536000, immutable");
6226
+ headers.set(
6227
+ "Cache-Control",
6228
+ scope === "app" ? "public, max-age=31536000, immutable" : "private, no-store"
6229
+ );
6137
6230
  headers.set("Access-Control-Allow-Origin", "*");
6138
6231
  if (object.customMetadata?.originalName) {
6139
6232
  headers.set("Content-Disposition", contentDisposition(object.customMetadata.originalName));
@@ -6148,7 +6241,7 @@ function resolveApiTransport(env) {
6148
6241
  return { kind: "url", baseUrl: env.API_WORKER_URL.replace(/\/$/, "") };
6149
6242
  }
6150
6243
  throw new Error(
6151
- "apiWorkerFetch: neither env.API_WORKER nor env.API_WORKER_URL is set. Add a [[services]] binding in wrangler.toml for production, or let `deepspace dev` write API_WORKER_URL into .dev.vars for local development."
6244
+ "apiWorkerFetch: neither env.API_WORKER nor env.API_WORKER_URL is set. Add a [[services]] binding in wrangler.toml for production, or let `deepspace dev start` write API_WORKER_URL into .dev.vars for local development."
6152
6245
  );
6153
6246
  }
6154
6247
  function resolvePlatformTransport(env) {
@@ -6157,7 +6250,7 @@ function resolvePlatformTransport(env) {
6157
6250
  return { kind: "url", baseUrl: env.PLATFORM_WORKER_URL.replace(/\/$/, "") };
6158
6251
  }
6159
6252
  throw new Error(
6160
- "platformWorkerFetch: neither env.PLATFORM_WORKER nor env.PLATFORM_WORKER_URL is set. Add a [[services]] binding in wrangler.toml for production, or let `deepspace dev` write PLATFORM_WORKER_URL into .dev.vars for local development."
6253
+ "platformWorkerFetch: neither env.PLATFORM_WORKER nor env.PLATFORM_WORKER_URL is set. Add a [[services]] binding in wrangler.toml for production, or let `deepspace dev start` write PLATFORM_WORKER_URL into .dev.vars for local development."
6161
6254
  );
6162
6255
  }
6163
6256
  function buildTargetUrl(transport, path, internalHost) {
@@ -6203,7 +6296,7 @@ function platformWorkerFetch(env, pathOrRequest, init) {
6203
6296
  function authWorkerFetch(env, path, init) {
6204
6297
  if (!env.AUTH_WORKER_URL) {
6205
6298
  throw new Error(
6206
- "authWorkerFetch: env.AUTH_WORKER_URL is not set. `deepspace dev` should write this into .dev.vars; for production, set it as a wrangler var or secret."
6299
+ "authWorkerFetch: env.AUTH_WORKER_URL is not set. `deepspace dev start` should write this into .dev.vars; for production, set it as a wrangler var or secret."
6207
6300
  );
6208
6301
  }
6209
6302
  const baseUrl = env.AUTH_WORKER_URL.replace(/\/$/, "");
@@ -6224,15 +6317,17 @@ function buildCronContext(env, ownerUserId, roomId = "default") {
6224
6317
  const roomIdObj = env.RECORD_ROOMS.idFromName(roomId);
6225
6318
  const room = env.RECORD_ROOMS.get(roomIdObj);
6226
6319
  async function executeTool3(tool2, params) {
6227
- const response = await room.fetch(new Request("https://internal/api/tools/execute", {
6228
- method: "POST",
6229
- headers: {
6230
- "Content-Type": "application/json",
6231
- "X-User-Id": ownerUserId,
6232
- "X-App-Action": "true"
6233
- },
6234
- body: JSON.stringify({ tool: tool2, params })
6235
- }));
6320
+ const response = await room.fetch(
6321
+ new Request("https://internal/api/tools/execute", {
6322
+ method: "POST",
6323
+ headers: {
6324
+ "Content-Type": "application/json",
6325
+ "X-User-Id": ownerUserId,
6326
+ "X-App-Action": "true"
6327
+ },
6328
+ body: JSON.stringify({ tool: tool2, params })
6329
+ })
6330
+ );
6236
6331
  const result = await response.json();
6237
6332
  if (!result.success) {
6238
6333
  throw new Error(`RecordRoom tool ${tool2} failed: ${result.error || "Unknown error"}`);
@@ -6262,7 +6357,7 @@ function buildCronContext(env, ownerUserId, roomId = "default") {
6262
6357
  async call(endpoint, params = {}) {
6263
6358
  if (!env.APP_OWNER_JWT) {
6264
6359
  throw new Error(
6265
- "integrations.call requires env.APP_OWNER_JWT. Deployed apps receive this automatically; in dev, `deepspace dev` writes it into .dev.vars."
6360
+ "integrations.call requires env.APP_OWNER_JWT. Deployed apps receive this automatically; in dev, `deepspace dev start` writes it into .dev.vars."
6266
6361
  );
6267
6362
  }
6268
6363
  const response = await apiWorkerFetch(env, `/api/integrations/${endpoint}`, {
@@ -6313,7 +6408,7 @@ function createDeepSpaceAI(env, provider, options = {}) {
6313
6408
  const authToken = options.authToken ?? env.APP_OWNER_JWT;
6314
6409
  if (!authToken) {
6315
6410
  throw new Error(
6316
- "createDeepSpaceAI: no auth token available. Either pass `options.authToken` explicitly (for user-initiated calls), or ensure `env.APP_OWNER_JWT` is set (injected at deploy time or by `deepspace dev`)."
6411
+ "createDeepSpaceAI: no auth token available. Either pass `options.authToken` explicitly (for user-initiated calls), or ensure `env.APP_OWNER_JWT` is set (injected at deploy time or by `deepspace dev start`)."
6317
6412
  );
6318
6413
  }
6319
6414
  const proxyFetch = (input, init) => {
@@ -6449,7 +6544,7 @@ function appendAppIdentity(headers, env) {
6449
6544
  }
6450
6545
 
6451
6546
  // src/server/utils/screenshot.ts
6452
- async function captureScreenshot(env, opts) {
6547
+ async function captureScreenshot(env, url) {
6453
6548
  const headers = new Headers({ "content-type": "application/json" });
6454
6549
  appendAppIdentity(headers, env);
6455
6550
  const res = await platformWorkerFetch(
@@ -6457,16 +6552,11 @@ async function captureScreenshot(env, opts) {
6457
6552
  new Request("https://platform-internal/internal/screenshot", {
6458
6553
  method: "POST",
6459
6554
  headers,
6460
- body: JSON.stringify(opts)
6555
+ body: JSON.stringify({ url })
6461
6556
  })
6462
6557
  );
6463
6558
  if (!res.ok) {
6464
- let detail = "";
6465
- try {
6466
- detail = (await res.text()).slice(0, 300);
6467
- } catch {
6468
- }
6469
- console.warn(`[captureScreenshot] platform returned ${res.status}:`, detail);
6559
+ console.warn(`[captureScreenshot] platform returned ${res.status}`);
6470
6560
  return null;
6471
6561
  }
6472
6562
  return {
@@ -6832,8 +6922,6 @@ async function executeTool2(stub, userId, tool2, params) {
6832
6922
  function toChatRow(env) {
6833
6923
  return {
6834
6924
  recordId: env.recordId,
6835
- // Deprecated alias — populated for back-compat with callers reading `id`.
6836
- id: env.recordId,
6837
6925
  userId: env.data.userId,
6838
6926
  title: env.data.title ?? "",
6839
6927
  model: env.data.model,
@@ -6846,8 +6934,6 @@ function toChatRow(env) {
6846
6934
  function toMessageRow(env) {
6847
6935
  return {
6848
6936
  recordId: env.recordId,
6849
- // Deprecated alias — populated for back-compat with callers reading `id`.
6850
- id: env.recordId,
6851
6937
  chatId: env.data.chatId,
6852
6938
  userId: env.data.userId,
6853
6939
  role: env.data.role,
@@ -7273,6 +7359,16 @@ function createDeepSpaceAuth(config) {
7273
7359
  database: config.database,
7274
7360
  baseURL: config.baseURL,
7275
7361
  secret: config.secret,
7362
+ // Login is a one-time human act; everything after it is agents minting
7363
+ // short-lived JWTs from this session. 30 days idle (vs the 7-day
7364
+ // default) with daily sliding renewal means any agent active within a
7365
+ // month keeps the session alive indefinitely — the human re-logs-in
7366
+ // only after a full month of zero activity. Short-lived access stays
7367
+ // short: CLI JWTs expire in minutes (auth-worker /api/auth/token).
7368
+ session: {
7369
+ expiresIn: 60 * 60 * 24 * 30,
7370
+ updateAge: 60 * 60 * 24
7371
+ },
7276
7372
  emailAndPassword: {
7277
7373
  enabled: config.emailAndPassword ?? true
7278
7374
  },
@@ -7301,9 +7397,41 @@ function createDeepSpaceAuth(config) {
7301
7397
  }
7302
7398
  }
7303
7399
  } : {},
7400
+ // Observer hook for new-user creation (see DeepSpaceAuthConfig.onUserCreated).
7401
+ // Two-layer wiring guard — be honest about what each layer catches:
7402
+ // 1. buildDatabaseHooks's return type is pinned to
7403
+ // BetterAuthOptions['databaseHooks'], so a better-auth upgrade that
7404
+ // renames/reshapes the option fails tsc.
7405
+ // 2. A LOCAL typo of this key would still compile (betterAuth's generic
7406
+ // options parameter defeats excess-property checking even for plain
7407
+ // keys) — that case is caught by the runtime assertions in
7408
+ // __tests__/betterAuth-hooks.test.ts, which read the wired hook back
7409
+ // off auth.options. Don't rename the key without running them.
7410
+ databaseHooks: buildDatabaseHooks(config.onUserCreated),
7304
7411
  plugins: [organization(), twoFactor()]
7305
7412
  });
7306
7413
  }
7414
+ function buildDatabaseHooks(onUserCreated) {
7415
+ if (!onUserCreated) return void 0;
7416
+ return {
7417
+ user: {
7418
+ create: {
7419
+ after: async (user, ctx) => {
7420
+ try {
7421
+ await onUserCreated(
7422
+ { id: user.id, email: user.email, name: user.name },
7423
+ // Forward BOTH request and standalone headers — some better-auth
7424
+ // entry points populate ctx.headers without a request.
7425
+ ctx ? { request: ctx.request, headers: ctx.headers ?? void 0 } : null
7426
+ );
7427
+ } catch (err) {
7428
+ console.error("[deepspace] onUserCreated hook failed (signup unaffected)", err);
7429
+ }
7430
+ }
7431
+ }
7432
+ }
7433
+ };
7434
+ }
7307
7435
  var cachedAppleSecret = null;
7308
7436
  async function generateAppleClientSecret(apple) {
7309
7437
  const now = Math.floor(Date.now() / 1e3);
@@ -7317,10 +7445,112 @@ async function generateAppleClientSecret(apple) {
7317
7445
  cachedAppleSecret = { keyId: apple.keyId, token, expiresAt };
7318
7446
  return token;
7319
7447
  }
7448
+
7449
+ // src/shared/client-errors.ts
7450
+ var CLIENT_LOG_MARKER = "[deepspace:client-error]";
7451
+ var CLIENT_ERROR_PATH = "/_deepspace/client-errors";
7452
+ var CLIENT_ERROR_CAPS = {
7453
+ message: 1024,
7454
+ stack: 4096,
7455
+ name: 256,
7456
+ url: 512
7457
+ };
7458
+ function capText(value, max) {
7459
+ if (typeof value !== "string") return void 0;
7460
+ return value.length > max ? value.slice(0, max) : value;
7461
+ }
7462
+ function finiteNumber(value) {
7463
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
7464
+ }
7465
+ function normalizeClientErrorReport(raw) {
7466
+ if (typeof raw !== "object" || raw === null) return null;
7467
+ const o = raw;
7468
+ const kind = o.kind === "unhandledrejection" || o.kind === "react" ? o.kind : "error";
7469
+ const message = capText(o.message, CLIENT_ERROR_CAPS.message) ?? "";
7470
+ const name = capText(o.name, CLIENT_ERROR_CAPS.name);
7471
+ const stack = capText(o.stack, CLIENT_ERROR_CAPS.stack);
7472
+ const url = capText(o.url, CLIENT_ERROR_CAPS.url);
7473
+ const line = finiteNumber(o.line);
7474
+ const col = finiteNumber(o.col);
7475
+ if (!message && !stack) return null;
7476
+ const report = { kind, message: message || (name ?? "Error") };
7477
+ if (name) report.name = name;
7478
+ if (stack) report.stack = stack;
7479
+ if (url) report.url = url;
7480
+ if (line !== void 0) report.line = line;
7481
+ if (col !== void 0) report.col = col;
7482
+ return report;
7483
+ }
7484
+ function clientErrorToLogLine(report) {
7485
+ return `${CLIENT_LOG_MARKER} ${JSON.stringify(report)}`;
7486
+ }
7487
+
7488
+ // src/server/client-errors.ts
7489
+ var THROTTLE_WINDOW_MS = 1e3;
7490
+ var THROTTLE_MAX = 20;
7491
+ var windowStart = 0;
7492
+ var windowCount = 0;
7493
+ var MAX_BODY_BYTES = 64 * 1024;
7494
+ function shouldThrottleClientError(now = Date.now()) {
7495
+ if (now - windowStart >= THROTTLE_WINDOW_MS) {
7496
+ windowStart = now;
7497
+ windowCount = 0;
7498
+ }
7499
+ windowCount += 1;
7500
+ return windowCount > THROTTLE_MAX;
7501
+ }
7502
+ function jsonResponse(body, status) {
7503
+ return new Response(JSON.stringify(body), {
7504
+ status,
7505
+ headers: { "Content-Type": "application/json" }
7506
+ });
7507
+ }
7508
+ async function handleClientErrorReport(request) {
7509
+ const site = request.headers.get("sec-fetch-site");
7510
+ if (site && site !== "same-origin" && site !== "none") {
7511
+ return new Response(null, { status: 403 });
7512
+ }
7513
+ if (shouldThrottleClientError()) {
7514
+ return new Response(null, { status: 429, headers: { "Retry-After": "1" } });
7515
+ }
7516
+ const declared = Number(request.headers.get("content-length"));
7517
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
7518
+ return jsonResponse({ error: "payload_too_large" }, 413);
7519
+ }
7520
+ let body;
7521
+ try {
7522
+ body = await request.json();
7523
+ } catch {
7524
+ return jsonResponse({ error: "invalid_json" }, 400);
7525
+ }
7526
+ const report = normalizeClientErrorReport(body);
7527
+ if (!report) return jsonResponse({ error: "invalid_report" }, 400);
7528
+ console.error(clientErrorToLogLine(report));
7529
+ return new Response(null, { status: 204 });
7530
+ }
7531
+ function registerClientErrorRoute(app) {
7532
+ app.post(CLIENT_ERROR_PATH, (c) => handleClientErrorReport(c.req.raw));
7533
+ }
7534
+
7535
+ // src/shared/log-events.ts
7536
+ var LOG_LEVELS = ["debug", "log", "info", "warn", "error"];
7537
+ var APP_LOG_EVENT_KEYS = [
7538
+ "id",
7539
+ "timestamp",
7540
+ "level",
7541
+ "eventType",
7542
+ "message",
7543
+ "source",
7544
+ "outcome",
7545
+ "request",
7546
+ "exception"
7547
+ ];
7320
7548
  export {
7321
7549
  AI_CHATS_SCHEMA,
7322
7550
  AI_MESSAGES_SCHEMA,
7323
7551
  ALLOWED_BINDING_TYPES,
7552
+ APP_ID_RE,
7553
+ APP_LOG_EVENT_KEYS,
7324
7554
  APP_NAME_RULES,
7325
7555
  AUTO_PROVISIONABLE_TYPES,
7326
7556
  AUTO_PROVISION_SENTINEL,
@@ -7331,6 +7561,7 @@ export {
7331
7561
  CHANNELS_SCHEMA,
7332
7562
  CHANNEL_INVITATIONS_SCHEMA,
7333
7563
  CHANNEL_MEMBERS_SCHEMA,
7564
+ CLIENT_LOG_MARKER,
7334
7565
  CONVERSATION_SCHEMAS,
7335
7566
  COST_RATES,
7336
7567
  CanvasRoom,
@@ -7338,11 +7569,13 @@ export {
7338
7569
  DEFAULT_CONTEXT_CONFIG,
7339
7570
  DEFAULT_DO_MANIFEST,
7340
7571
  DEFAULT_QUERY_LIMIT,
7572
+ DEPLOYED_APPS_DEFAULTS,
7341
7573
  DIRECTORY_SCHEMAS,
7342
7574
  GLOBAL_DO_TYPES,
7343
7575
  GLOBAL_DO_TYPE_NAMES,
7344
7576
  GameRoom,
7345
7577
  JobRoom,
7578
+ LOG_LEVELS,
7346
7579
  MESSAGES_SCHEMA,
7347
7580
  MSG,
7348
7581
  MSG_AWARENESS,
@@ -7355,12 +7588,15 @@ export {
7355
7588
  PresenceRoom,
7356
7589
  REACTIONS_SCHEMA,
7357
7590
  READ_RECEIPTS_SCHEMA,
7591
+ REGISTRY_INSTANCE,
7358
7592
  RESERVED_BINDING_NAMES,
7359
7593
  RESERVED_COLLECTION_NAMES,
7360
7594
  ROLE_ADMIN,
7361
7595
  ROLE_ANONYMOUS,
7362
7596
  ROLE_DEFAULT,
7363
7597
  RecordRoom,
7598
+ RegistryClientError,
7599
+ STRICT_APP_ID_RE,
7364
7600
  SYSTEM_COLLECTIONS,
7365
7601
  SYSTEM_MANAGED_COLUMNS,
7366
7602
  SchemaRegistry,
@@ -7379,6 +7615,7 @@ export {
7379
7615
  buildCronContext,
7380
7616
  buildTableSelect,
7381
7617
  buildUiParts,
7618
+ callRegistry,
7382
7619
  canCreate,
7383
7620
  canDelete,
7384
7621
  canRead,
@@ -7415,6 +7652,7 @@ export {
7415
7652
  getMessageType,
7416
7653
  getRolePermissions,
7417
7654
  handleAwarenessMessage,
7655
+ handleClientErrorReport,
7418
7656
  handleSyncMessage,
7419
7657
  hasContent,
7420
7658
  isAutoProvision,
@@ -7425,15 +7663,21 @@ export {
7425
7663
  lintSchema,
7426
7664
  loadMessages,
7427
7665
  makeDefaultSummarizer,
7666
+ matchOrigin,
7428
7667
  meterAi,
7429
7668
  meterUsage,
7430
7669
  meterVectorize,
7431
7670
  noopPermissionContext,
7671
+ parseQuotaLimit,
7672
+ platformCorsPatterns,
7432
7673
  platformWorkerFetch,
7433
7674
  prepareMessagesWithCompaction,
7434
7675
  priceBindingUsageEvent,
7435
7676
  readVarUint,
7436
7677
  readVarUint8Array,
7678
+ registerClientErrorRoute,
7679
+ registryErrorJson,
7680
+ registryStub,
7437
7681
  resolveAppName,
7438
7682
  resolveColumn,
7439
7683
  rowToData,