@agent-native/core 0.45.1 → 0.46.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 (65) hide show
  1. package/README.md +1 -0
  2. package/dist/client/components/LiveCursorOverlay.d.ts +46 -0
  3. package/dist/client/components/LiveCursorOverlay.d.ts.map +1 -0
  4. package/dist/client/components/LiveCursorOverlay.js +137 -0
  5. package/dist/client/components/LiveCursorOverlay.js.map +1 -0
  6. package/dist/client/components/PresenceBar.d.ts +11 -1
  7. package/dist/client/components/PresenceBar.d.ts.map +1 -1
  8. package/dist/client/components/PresenceBar.js +39 -7
  9. package/dist/client/components/PresenceBar.js.map +1 -1
  10. package/dist/client/components/RemoteSelectionRings.d.ts +43 -0
  11. package/dist/client/components/RemoteSelectionRings.d.ts.map +1 -0
  12. package/dist/client/components/RemoteSelectionRings.js +116 -0
  13. package/dist/client/components/RemoteSelectionRings.js.map +1 -0
  14. package/dist/client/index.d.ts +4 -0
  15. package/dist/client/index.d.ts.map +1 -1
  16. package/dist/client/index.js +5 -0
  17. package/dist/client/index.js.map +1 -1
  18. package/dist/collab/awareness.d.ts +25 -0
  19. package/dist/collab/awareness.d.ts.map +1 -1
  20. package/dist/collab/awareness.js +42 -5
  21. package/dist/collab/awareness.js.map +1 -1
  22. package/dist/collab/client.d.ts +19 -1
  23. package/dist/collab/client.d.ts.map +1 -1
  24. package/dist/collab/client.js +362 -57
  25. package/dist/collab/client.js.map +1 -1
  26. package/dist/collab/follow-mode.d.ts +56 -0
  27. package/dist/collab/follow-mode.d.ts.map +1 -0
  28. package/dist/collab/follow-mode.js +54 -0
  29. package/dist/collab/follow-mode.js.map +1 -0
  30. package/dist/collab/index.d.ts +3 -1
  31. package/dist/collab/index.d.ts.map +1 -1
  32. package/dist/collab/index.js +5 -1
  33. package/dist/collab/index.js.map +1 -1
  34. package/dist/collab/presence.d.ts +56 -0
  35. package/dist/collab/presence.d.ts.map +1 -0
  36. package/dist/collab/presence.js +98 -0
  37. package/dist/collab/presence.js.map +1 -0
  38. package/dist/collab/routes.d.ts.map +1 -1
  39. package/dist/collab/routes.js +33 -6
  40. package/dist/collab/routes.js.map +1 -1
  41. package/dist/collab/struct-routes.d.ts.map +1 -1
  42. package/dist/collab/struct-routes.js +24 -4
  43. package/dist/collab/struct-routes.js.map +1 -1
  44. package/dist/collab/ydoc-manager.d.ts +13 -0
  45. package/dist/collab/ydoc-manager.d.ts.map +1 -1
  46. package/dist/collab/ydoc-manager.js +51 -15
  47. package/dist/collab/ydoc-manager.js.map +1 -1
  48. package/dist/server/collab-plugin.d.ts +6 -0
  49. package/dist/server/collab-plugin.d.ts.map +1 -1
  50. package/dist/server/collab-plugin.js +105 -5
  51. package/dist/server/collab-plugin.js.map +1 -1
  52. package/dist/server/poll-events.d.ts +5 -0
  53. package/dist/server/poll-events.d.ts.map +1 -1
  54. package/dist/server/poll-events.js +27 -4
  55. package/dist/server/poll-events.js.map +1 -1
  56. package/dist/templates/default/.agents/skills/real-time-collab/SKILL.md +185 -37
  57. package/dist/templates/default/.agents/skills/real-time-sync/SKILL.md +12 -2
  58. package/dist/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +185 -37
  59. package/dist/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +12 -2
  60. package/docs/content/real-time-collaboration.md +481 -97
  61. package/package.json +1 -1
  62. package/src/templates/default/.agents/skills/real-time-collab/SKILL.md +185 -37
  63. package/src/templates/default/.agents/skills/real-time-sync/SKILL.md +12 -2
  64. package/src/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +185 -37
  65. package/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +12 -2
@@ -23,15 +23,92 @@ import { getSession } from "./auth.js";
23
23
  import { getOrgContext } from "../org/context.js";
24
24
  import { runWithRequestContext } from "./request-context.js";
25
25
  import { resolveAccess, assertAccess } from "../sharing/access.js";
26
+ /** Default maximum body size in bytes for collab write operations (2 MB). */
27
+ const DEFAULT_MAX_PAYLOAD_BYTES = 2 * 1024 * 1024;
28
+ /**
29
+ * Whether the no-resourceType warning has already been logged once per server
30
+ * process. Avoids flooding logs on every request in templates that deliberately
31
+ * have no sharing model.
32
+ */
33
+ let _unscoped_warning_logged = false;
26
34
  export function createCollabPlugin(options = {}) {
27
- const { table = "documents", contentColumn = "content", idColumn = "id", autoSeed = true, resourceType, resolveResourceId, } = options;
35
+ const { table = "documents", contentColumn = "content", idColumn = "id", autoSeed = true, resourceType, resolveResourceId, maxPayloadBytes = DEFAULT_MAX_PAYLOAD_BYTES, } = options;
28
36
  return async (nitroApp) => {
29
37
  await awaitBootstrap(nitroApp);
30
38
  const P = FRAMEWORK_ROUTE_PREFIX;
31
- // Wire collab emitter → poll ring buffer so clients receive Yjs updates
39
+ // Wire collab emitter → poll ring buffer so clients receive Yjs updates.
40
+ // Security: when resourceType is configured, resolve the resource's
41
+ // owner/org so getChangesSinceForUser can scope delivery. We use
42
+ // resolveAccess to obtain the resource row — it already handles ownership,
43
+ // visibility, and share rows. Rather than trying to enumerate every sharee
44
+ // (which would require querying the shares table for every update), the
45
+ // conservative strategy is:
46
+ // • tag the event with the resource owner's email and org (owner-scoped).
47
+ // • non-owner sharees who have explicit viewer+ access will NOT receive
48
+ // the push event; they fall back to the state-vector catch-up via the
49
+ // poll loop. This is safe (never delivers to someone without access)
50
+ // at the cost of sharees seeing slightly higher latency (state-vector
51
+ // fetch on the next poll cycle) rather than the push path.
52
+ // See also: SECURITY comment in poll.ts on getChangesSinceForUser.
53
+ if (!resourceType && !_unscoped_warning_logged) {
54
+ _unscoped_warning_logged = true;
55
+ console.warn("[collab] WARNING: createCollabPlugin called without resourceType. " +
56
+ "Collab events will be delivered to ALL authenticated users on this deployment " +
57
+ "without document-level access scoping. Set resourceType to enable access-scoped delivery.");
58
+ }
32
59
  const collabEmitter = getCollabEmitter();
33
- collabEmitter.on("collab", (event) => {
34
- recordChange(event);
60
+ collabEmitter.on("collab", async (event) => {
61
+ if (!resourceType) {
62
+ // No access model — broadcast to all authenticated users (no owner/orgId tag).
63
+ recordChange(event);
64
+ return;
65
+ }
66
+ // Resolve the resource to learn its owner/org so we can scope the event.
67
+ const docId = event.docId;
68
+ if (!docId) {
69
+ recordChange(event);
70
+ return;
71
+ }
72
+ try {
73
+ const resourceId = resolveResourceId
74
+ ? await resolveResourceId(docId)
75
+ : docId;
76
+ if (!resourceId) {
77
+ // Cannot resolve resource — drop the event to avoid leaking to
78
+ // unauthorized pollers. The client will catch up via state-vector.
79
+ return;
80
+ }
81
+ // Load the resource row to get owner/org. resolveAccess fetches the
82
+ // resource row internally; use getShareableResource to read it cheaply.
83
+ const { requireShareableResource } = await import("../sharing/registry.js");
84
+ const reg = requireShareableResource(resourceType);
85
+ const db = reg.getDb();
86
+ const { eq } = await import("drizzle-orm");
87
+ const [resource] = await db
88
+ .select()
89
+ .from(reg.resourceTable)
90
+ .where(eq(reg.resourceTable.id, resourceId))
91
+ .limit(1);
92
+ if (!resource) {
93
+ // Resource deleted — drop silently.
94
+ return;
95
+ }
96
+ const ownerEmail = typeof resource.ownerEmail === "string"
97
+ ? resource.ownerEmail
98
+ : undefined;
99
+ const orgId = typeof resource.orgId === "string" ? resource.orgId : undefined;
100
+ // Tag the event with owner/org. Non-owner sharees fall back to poll;
101
+ // this is acceptable (see comment above).
102
+ recordChange({
103
+ ...event,
104
+ ...(ownerEmail ? { owner: ownerEmail } : {}),
105
+ ...(orgId ? { orgId } : {}),
106
+ });
107
+ }
108
+ catch {
109
+ // If we fail to resolve the resource (DB not ready, etc.) we skip
110
+ // the event rather than broadcasting it without scoping.
111
+ }
35
112
  });
36
113
  // Mount collab routes — manual method dispatch since the path layout is
37
114
  // `/collab/:docId/<action>`. The framework strips the `/collab` mount
@@ -59,7 +136,10 @@ export function createCollabPlugin(options = {}) {
59
136
  const userEmail = session.email;
60
137
  const orgId = orgCtx?.orgId ?? undefined;
61
138
  return runWithRequestContext({ userEmail, orgId }, async () => {
62
- // Access check — require at least viewer for reads, editor for writes
139
+ // Access check — require at least viewer for reads, editor for writes.
140
+ // Awareness routes (POST awareness / GET users) require the same
141
+ // level as other reads so that knowledge of who is editing a doc
142
+ // doesn't leak to users without access.
63
143
  if (resourceType) {
64
144
  const resourceId = resolveResourceId
65
145
  ? await resolveResourceId(docId)
@@ -86,6 +166,26 @@ export function createCollabPlugin(options = {}) {
86
166
  }
87
167
  }
88
168
  }
169
+ // Payload size limit for write operations
170
+ const isWriteAction = (action === "update" && method === "POST") ||
171
+ (action === "text" && method === "POST") ||
172
+ (action === "search-replace" && method === "POST") ||
173
+ (action === "json" && method === "POST") ||
174
+ (action === "patch" && method === "POST");
175
+ if (isWriteAction) {
176
+ const contentLength = Number(event.headers?.get?.("content-length") ?? NaN);
177
+ if (!isNaN(contentLength) && contentLength > maxPayloadBytes) {
178
+ setResponseStatus(event, 413);
179
+ return {
180
+ error: `Payload too large. Maximum is ${maxPayloadBytes} bytes.`,
181
+ };
182
+ }
183
+ // Store limit in context so route handlers can enforce it on the
184
+ // parsed body when content-length is absent or spoofed.
185
+ if (event.context) {
186
+ event.context._collabMaxPayloadBytes = maxPayloadBytes;
187
+ }
188
+ }
89
189
  if (action === "state" && method === "GET")
90
190
  return getCollabState(event);
91
191
  if (action === "update" && method === "POST")
@@ -1 +1 @@
1
- {"version":3,"file":"collab-plugin.js","sourceRoot":"","sources":["../../src/server/collab-plugin.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Nitro plugin that mounts collaborative editing routes.\n *\n * Templates opt in with one line:\n * ```ts\n * // server/plugins/collab.ts\n * import { createCollabPlugin } from \"@agent-native/core/server\";\n * export default createCollabPlugin({ table: \"documents\", contentColumn: \"content\" });\n * ```\n */\n\nimport {\n defineEventHandler,\n getMethod,\n setResponseStatus,\n type H3Event,\n} from \"h3\";\nimport { getH3App, awaitBootstrap } from \"./framework-request-handler.js\";\nimport { FRAMEWORK_ROUTE_PREFIX } from \"./core-routes-plugin.js\";\nimport {\n getCollabState,\n postCollabUpdate,\n postCollabText,\n postCollabSearchReplace,\n} from \"../collab/routes.js\";\nimport {\n postCollabJson,\n getCollabJson,\n postCollabPatch,\n} from \"../collab/struct-routes.js\";\nimport { postAwareness, getActiveUsers } from \"../collab/awareness.js\";\nimport { seedFromText, seedFromJson } from \"../collab/ydoc-manager.js\";\nimport { hasCollabState } from \"../collab/storage.js\";\nimport { getDbExec } from \"../db/client.js\";\nimport { getCollabEmitter } from \"../collab/emitter.js\";\nimport { recordChange } from \"./poll.js\";\nimport { getSession } from \"./auth.js\";\nimport { getOrgContext } from \"../org/context.js\";\nimport { runWithRequestContext } from \"./request-context.js\";\nimport { resolveAccess, assertAccess } from \"../sharing/access.js\";\n\ntype NitroPluginDef = (nitroApp: any) => void | Promise<void>;\n\nexport interface CollabPluginOptions {\n /** Table name containing document content. Default: \"documents\" */\n table?: string;\n /** Column name for text content. Default: \"content\" */\n contentColumn?: string;\n /** Column name for the document ID. Default: \"id\" */\n idColumn?: string;\n /** Whether to auto-seed existing documents on startup. Default: true */\n autoSeed?: boolean;\n /**\n * Callback invoked after a collab update to sync the content column.\n * If not provided, the plugin auto-syncs using table/contentColumn/idColumn.\n */\n onContentSync?: (docId: string, text: string) => Promise<void>;\n /** Content type: \"text\" for Y.Text (default) or \"json\" for Y.Map/Y.Array. */\n contentType?: \"text\" | \"json\";\n /** Column name for JSON content (used when contentType is \"json\"). */\n jsonColumn?: string;\n /**\n * The shareable resource type registered via `registerShareableResource`.\n * Used to enforce access checks on collab routes.\n * Omit only for resources that are always public (no sharing model).\n */\n resourceType?: string;\n /**\n * Map the collab document id to the shareable resource id. Many templates\n * use route-specific collab ids (for example, one doc per slide inside a\n * deck) while sharing is enforced at the parent resource level.\n */\n resolveResourceId?: (docId: string) => string | null | Promise<string | null>;\n}\n\nexport function createCollabPlugin(\n options: CollabPluginOptions = {},\n): NitroPluginDef {\n const {\n table = \"documents\",\n contentColumn = \"content\",\n idColumn = \"id\",\n autoSeed = true,\n resourceType,\n resolveResourceId,\n } = options;\n\n return async (nitroApp: any) => {\n await awaitBootstrap(nitroApp);\n const P = FRAMEWORK_ROUTE_PREFIX;\n\n // Wire collab emitter → poll ring buffer so clients receive Yjs updates\n const collabEmitter = getCollabEmitter();\n collabEmitter.on(\"collab\", (event) => {\n recordChange(event);\n });\n\n // Mount collab routes — manual method dispatch since the path layout is\n // `/collab/:docId/<action>`. The framework strips the `/collab` mount\n // prefix from event.url.pathname before calling us, so we see e.g.\n // `/abc-123/state`.\n getH3App(nitroApp).use(\n `${P}/collab`,\n defineEventHandler(async (event: H3Event) => {\n const parts = (event.url?.pathname || \"\")\n .replace(/^\\/+/, \"\")\n .split(\"/\");\n const docId = parts[0] || \"\";\n const action = parts[1] || \"\";\n if (!docId) return;\n if (event.context) {\n event.context.params = { ...event.context.params, docId };\n }\n const method = getMethod(event);\n\n // Auth check — all collab routes require a session\n const session = await getSession(event).catch(() => null);\n if (!session?.email) {\n setResponseStatus(event, 401);\n return { error: \"Authentication required\" };\n }\n\n const orgCtx = await getOrgContext(event).catch(() => null);\n const userEmail = session.email;\n const orgId = orgCtx?.orgId ?? undefined;\n\n return runWithRequestContext({ userEmail, orgId }, async () => {\n // Access check — require at least viewer for reads, editor for writes\n if (resourceType) {\n const resourceId = resolveResourceId\n ? await resolveResourceId(docId)\n : docId;\n if (!resourceId) {\n setResponseStatus(event, 404);\n return { error: \"Not found\" };\n }\n const isWrite =\n (action === \"update\" && method === \"POST\") ||\n (action === \"text\" && method === \"POST\") ||\n (action === \"search-replace\" && method === \"POST\") ||\n (action === \"json\" && method === \"POST\") ||\n (action === \"patch\" && method === \"POST\");\n\n if (isWrite) {\n // assertAccess throws ForbiddenError (→ 403) if no editor access\n await assertAccess(resourceType, resourceId, \"editor\");\n } else {\n // resolveAccess returns null when no access; return 404 to avoid leaking existence\n const access = await resolveAccess(resourceType, resourceId);\n if (!access) {\n setResponseStatus(event, 404);\n return { error: \"Not found\" };\n }\n }\n }\n\n if (action === \"state\" && method === \"GET\")\n return getCollabState(event);\n if (action === \"update\" && method === \"POST\")\n return postCollabUpdate(event);\n if (action === \"text\" && method === \"POST\")\n return postCollabText(event);\n if (action === \"search-replace\" && method === \"POST\")\n return postCollabSearchReplace(event);\n if (action === \"json\" && method === \"POST\")\n return postCollabJson(event);\n if (action === \"json\" && method === \"GET\")\n return getCollabJson(event);\n if (action === \"patch\" && method === \"POST\")\n return postCollabPatch(event);\n if (action === \"awareness\" && method === \"POST\")\n return postAwareness(event);\n if (action === \"users\" && method === \"GET\")\n return getActiveUsers(event);\n setResponseStatus(event, 404);\n return { error: \"Not found\" };\n });\n }),\n );\n\n // Auto-seed existing documents into collab state\n if (autoSeed) {\n const isJson = options.contentType === \"json\";\n const seedColumn = isJson\n ? options.jsonColumn || contentColumn\n : contentColumn;\n\n // Run in background so it doesn't block startup\n setTimeout(async () => {\n try {\n const client = getDbExec();\n const { rows } = await client.execute(\n `SELECT ${idColumn}, ${seedColumn} FROM ${table}`,\n );\n for (const row of rows) {\n const docId = row[idColumn] as string;\n const exists = await hasCollabState(docId);\n if (exists) continue;\n\n if (isJson) {\n const raw = (row[seedColumn] as string) ?? \"{}\";\n try {\n const parsed = JSON.parse(raw);\n const inferredType: \"map\" | \"array\" = Array.isArray(parsed)\n ? \"array\"\n : \"map\";\n await seedFromJson(docId, parsed, \"data\", inferredType);\n } catch {\n // Invalid JSON — skip\n }\n } else {\n const content = (row[seedColumn] as string) ?? \"\";\n await seedFromText(docId, content);\n }\n }\n } catch {\n // Table may not exist yet on first boot — that's fine\n }\n }, 1000);\n }\n };\n}\n"]}
1
+ {"version":3,"file":"collab-plugin.js","sourceRoot":"","sources":["../../src/server/collab-plugin.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Nitro plugin that mounts collaborative editing routes.\n *\n * Templates opt in with one line:\n * ```ts\n * // server/plugins/collab.ts\n * import { createCollabPlugin } from \"@agent-native/core/server\";\n * export default createCollabPlugin({ table: \"documents\", contentColumn: \"content\" });\n * ```\n */\n\nimport {\n defineEventHandler,\n getMethod,\n setResponseStatus,\n type H3Event,\n} from \"h3\";\nimport { getH3App, awaitBootstrap } from \"./framework-request-handler.js\";\nimport { FRAMEWORK_ROUTE_PREFIX } from \"./core-routes-plugin.js\";\nimport {\n getCollabState,\n postCollabUpdate,\n postCollabText,\n postCollabSearchReplace,\n} from \"../collab/routes.js\";\nimport {\n postCollabJson,\n getCollabJson,\n postCollabPatch,\n} from \"../collab/struct-routes.js\";\nimport { postAwareness, getActiveUsers } from \"../collab/awareness.js\";\nimport { seedFromText, seedFromJson } from \"../collab/ydoc-manager.js\";\nimport { hasCollabState } from \"../collab/storage.js\";\nimport { getDbExec } from \"../db/client.js\";\nimport { getCollabEmitter } from \"../collab/emitter.js\";\nimport { recordChange } from \"./poll.js\";\nimport { getSession } from \"./auth.js\";\nimport { getOrgContext } from \"../org/context.js\";\nimport { runWithRequestContext } from \"./request-context.js\";\nimport { resolveAccess, assertAccess } from \"../sharing/access.js\";\n\ntype NitroPluginDef = (nitroApp: any) => void | Promise<void>;\n\n/** Default maximum body size in bytes for collab write operations (2 MB). */\nconst DEFAULT_MAX_PAYLOAD_BYTES = 2 * 1024 * 1024;\n\n/**\n * Whether the no-resourceType warning has already been logged once per server\n * process. Avoids flooding logs on every request in templates that deliberately\n * have no sharing model.\n */\nlet _unscoped_warning_logged = false;\n\nexport interface CollabPluginOptions {\n /** Table name containing document content. Default: \"documents\" */\n table?: string;\n /** Column name for text content. Default: \"content\" */\n contentColumn?: string;\n /** Column name for the document ID. Default: \"id\" */\n idColumn?: string;\n /** Whether to auto-seed existing documents on startup. Default: true */\n autoSeed?: boolean;\n /**\n * Callback invoked after a collab update to sync the content column.\n * If not provided, the plugin auto-syncs using table/contentColumn/idColumn.\n */\n onContentSync?: (docId: string, text: string) => Promise<void>;\n /** Content type: \"text\" for Y.Text (default) or \"json\" for Y.Map/Y.Array. */\n contentType?: \"text\" | \"json\";\n /** Column name for JSON content (used when contentType is \"json\"). */\n jsonColumn?: string;\n /**\n * The shareable resource type registered via `registerShareableResource`.\n * Used to enforce access checks on collab routes.\n * Omit only for resources that are always public (no sharing model).\n */\n resourceType?: string;\n /**\n * Map the collab document id to the shareable resource id. Many templates\n * use route-specific collab ids (for example, one doc per slide inside a\n * deck) while sharing is enforced at the parent resource level.\n */\n resolveResourceId?: (docId: string) => string | null | Promise<string | null>;\n /**\n * Maximum allowed body size in bytes for write operations\n * (update/text/json/patch). Requests exceeding this are rejected with 413.\n * Default: 2097152 (2 MB).\n */\n maxPayloadBytes?: number;\n}\n\nexport function createCollabPlugin(\n options: CollabPluginOptions = {},\n): NitroPluginDef {\n const {\n table = \"documents\",\n contentColumn = \"content\",\n idColumn = \"id\",\n autoSeed = true,\n resourceType,\n resolveResourceId,\n maxPayloadBytes = DEFAULT_MAX_PAYLOAD_BYTES,\n } = options;\n\n return async (nitroApp: any) => {\n await awaitBootstrap(nitroApp);\n const P = FRAMEWORK_ROUTE_PREFIX;\n\n // Wire collab emitter → poll ring buffer so clients receive Yjs updates.\n // Security: when resourceType is configured, resolve the resource's\n // owner/org so getChangesSinceForUser can scope delivery. We use\n // resolveAccess to obtain the resource row — it already handles ownership,\n // visibility, and share rows. Rather than trying to enumerate every sharee\n // (which would require querying the shares table for every update), the\n // conservative strategy is:\n // • tag the event with the resource owner's email and org (owner-scoped).\n // • non-owner sharees who have explicit viewer+ access will NOT receive\n // the push event; they fall back to the state-vector catch-up via the\n // poll loop. This is safe (never delivers to someone without access)\n // at the cost of sharees seeing slightly higher latency (state-vector\n // fetch on the next poll cycle) rather than the push path.\n // See also: SECURITY comment in poll.ts on getChangesSinceForUser.\n if (!resourceType && !_unscoped_warning_logged) {\n _unscoped_warning_logged = true;\n console.warn(\n \"[collab] WARNING: createCollabPlugin called without resourceType. \" +\n \"Collab events will be delivered to ALL authenticated users on this deployment \" +\n \"without document-level access scoping. Set resourceType to enable access-scoped delivery.\",\n );\n }\n\n const collabEmitter = getCollabEmitter();\n collabEmitter.on(\"collab\", async (event) => {\n if (!resourceType) {\n // No access model — broadcast to all authenticated users (no owner/orgId tag).\n recordChange(event);\n return;\n }\n\n // Resolve the resource to learn its owner/org so we can scope the event.\n const docId = event.docId as string | undefined;\n if (!docId) {\n recordChange(event);\n return;\n }\n\n try {\n const resourceId = resolveResourceId\n ? await resolveResourceId(docId)\n : docId;\n if (!resourceId) {\n // Cannot resolve resource — drop the event to avoid leaking to\n // unauthorized pollers. The client will catch up via state-vector.\n return;\n }\n\n // Load the resource row to get owner/org. resolveAccess fetches the\n // resource row internally; use getShareableResource to read it cheaply.\n const { requireShareableResource } =\n await import(\"../sharing/registry.js\");\n const reg = requireShareableResource(resourceType);\n const db = reg.getDb() as any;\n const { eq } = await import(\"drizzle-orm\");\n const [resource] = await db\n .select()\n .from(reg.resourceTable)\n .where(eq(reg.resourceTable.id, resourceId))\n .limit(1);\n\n if (!resource) {\n // Resource deleted — drop silently.\n return;\n }\n\n const ownerEmail =\n typeof resource.ownerEmail === \"string\"\n ? resource.ownerEmail\n : undefined;\n const orgId =\n typeof resource.orgId === \"string\" ? resource.orgId : undefined;\n\n // Tag the event with owner/org. Non-owner sharees fall back to poll;\n // this is acceptable (see comment above).\n recordChange({\n ...event,\n ...(ownerEmail ? { owner: ownerEmail } : {}),\n ...(orgId ? { orgId } : {}),\n });\n } catch {\n // If we fail to resolve the resource (DB not ready, etc.) we skip\n // the event rather than broadcasting it without scoping.\n }\n });\n\n // Mount collab routes — manual method dispatch since the path layout is\n // `/collab/:docId/<action>`. The framework strips the `/collab` mount\n // prefix from event.url.pathname before calling us, so we see e.g.\n // `/abc-123/state`.\n getH3App(nitroApp).use(\n `${P}/collab`,\n defineEventHandler(async (event: H3Event) => {\n const parts = (event.url?.pathname || \"\")\n .replace(/^\\/+/, \"\")\n .split(\"/\");\n const docId = parts[0] || \"\";\n const action = parts[1] || \"\";\n if (!docId) return;\n if (event.context) {\n event.context.params = { ...event.context.params, docId };\n }\n const method = getMethod(event);\n\n // Auth check — all collab routes require a session\n const session = await getSession(event).catch(() => null);\n if (!session?.email) {\n setResponseStatus(event, 401);\n return { error: \"Authentication required\" };\n }\n\n const orgCtx = await getOrgContext(event).catch(() => null);\n const userEmail = session.email;\n const orgId = orgCtx?.orgId ?? undefined;\n\n return runWithRequestContext({ userEmail, orgId }, async () => {\n // Access check — require at least viewer for reads, editor for writes.\n // Awareness routes (POST awareness / GET users) require the same\n // level as other reads so that knowledge of who is editing a doc\n // doesn't leak to users without access.\n if (resourceType) {\n const resourceId = resolveResourceId\n ? await resolveResourceId(docId)\n : docId;\n if (!resourceId) {\n setResponseStatus(event, 404);\n return { error: \"Not found\" };\n }\n const isWrite =\n (action === \"update\" && method === \"POST\") ||\n (action === \"text\" && method === \"POST\") ||\n (action === \"search-replace\" && method === \"POST\") ||\n (action === \"json\" && method === \"POST\") ||\n (action === \"patch\" && method === \"POST\");\n\n if (isWrite) {\n // assertAccess throws ForbiddenError (→ 403) if no editor access\n await assertAccess(resourceType, resourceId, \"editor\");\n } else {\n // resolveAccess returns null when no access; return 404 to avoid leaking existence\n const access = await resolveAccess(resourceType, resourceId);\n if (!access) {\n setResponseStatus(event, 404);\n return { error: \"Not found\" };\n }\n }\n }\n\n // Payload size limit for write operations\n const isWriteAction =\n (action === \"update\" && method === \"POST\") ||\n (action === \"text\" && method === \"POST\") ||\n (action === \"search-replace\" && method === \"POST\") ||\n (action === \"json\" && method === \"POST\") ||\n (action === \"patch\" && method === \"POST\");\n\n if (isWriteAction) {\n const contentLength = Number(\n event.headers?.get?.(\"content-length\") ?? NaN,\n );\n if (!isNaN(contentLength) && contentLength > maxPayloadBytes) {\n setResponseStatus(event, 413);\n return {\n error: `Payload too large. Maximum is ${maxPayloadBytes} bytes.`,\n };\n }\n // Store limit in context so route handlers can enforce it on the\n // parsed body when content-length is absent or spoofed.\n if (event.context) {\n event.context._collabMaxPayloadBytes = maxPayloadBytes;\n }\n }\n\n if (action === \"state\" && method === \"GET\")\n return getCollabState(event);\n if (action === \"update\" && method === \"POST\")\n return postCollabUpdate(event);\n if (action === \"text\" && method === \"POST\")\n return postCollabText(event);\n if (action === \"search-replace\" && method === \"POST\")\n return postCollabSearchReplace(event);\n if (action === \"json\" && method === \"POST\")\n return postCollabJson(event);\n if (action === \"json\" && method === \"GET\")\n return getCollabJson(event);\n if (action === \"patch\" && method === \"POST\")\n return postCollabPatch(event);\n if (action === \"awareness\" && method === \"POST\")\n return postAwareness(event);\n if (action === \"users\" && method === \"GET\")\n return getActiveUsers(event);\n setResponseStatus(event, 404);\n return { error: \"Not found\" };\n });\n }),\n );\n\n // Auto-seed existing documents into collab state\n if (autoSeed) {\n const isJson = options.contentType === \"json\";\n const seedColumn = isJson\n ? options.jsonColumn || contentColumn\n : contentColumn;\n\n // Run in background so it doesn't block startup\n setTimeout(async () => {\n try {\n const client = getDbExec();\n const { rows } = await client.execute(\n `SELECT ${idColumn}, ${seedColumn} FROM ${table}`,\n );\n for (const row of rows) {\n const docId = row[idColumn] as string;\n const exists = await hasCollabState(docId);\n if (exists) continue;\n\n if (isJson) {\n const raw = (row[seedColumn] as string) ?? \"{}\";\n try {\n const parsed = JSON.parse(raw);\n const inferredType: \"map\" | \"array\" = Array.isArray(parsed)\n ? \"array\"\n : \"map\";\n await seedFromJson(docId, parsed, \"data\", inferredType);\n } catch {\n // Invalid JSON — skip\n }\n } else {\n const content = (row[seedColumn] as string) ?? \"\";\n await seedFromText(docId, content);\n }\n }\n } catch {\n // Table may not exist yet on first boot — that's fine\n }\n }, 1000);\n }\n };\n}\n"]}
@@ -5,6 +5,11 @@
5
5
  * server process. The regular /poll endpoint remains the cross-process and
6
6
  * serverless cold-start fallback because it can detect DB timestamp changes
7
7
  * even when the write happened somewhere this EventEmitter could not see.
8
+ *
9
+ * Also forwards awareness change events (cursor/presence updates) so
10
+ * connected peers receive cursor moves push-style instead of waiting for
11
+ * the next poll cycle. Polling fallback keeps working — cursors degrade
12
+ * gracefully to poll cadence without SSE.
8
13
  */
9
14
  export declare function createPollEventsHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<string | URLSearchParams | ReadableStream<any> | Blob | ArrayBuffer | ArrayBufferView<ArrayBuffer> | FormData | {
10
15
  error: string;
@@ -1 +1 @@
1
- {"version":3,"file":"poll-events.d.ts","sourceRoot":"","sources":["../../src/server/poll-events.ts"],"names":[],"mappings":"AASA;;;;;;;GAOG;AACH,wBAAgB,uBAAuB;;IA8BtC"}
1
+ {"version":3,"file":"poll-events.d.ts","sourceRoot":"","sources":["../../src/server/poll-events.ts"],"names":[],"mappings":"AAcA;;;;;;;;;;;;GAYG;AACH,wBAAgB,uBAAuB;;IA+CtC"}
@@ -1,6 +1,7 @@
1
1
  import { createEventStream, defineEventHandler, setResponseStatus } from "h3";
2
2
  import { getSession } from "./auth.js";
3
3
  import { canSeeChangeForUser, getPollEmitter, POLL_CHANGE_EVENT, } from "./poll.js";
4
+ import { getAwarenessEmitter, AWARENESS_CHANGE_EVENT, } from "../collab/awareness.js";
4
5
  /**
5
6
  * Stream in-process poll events over SSE.
6
7
  *
@@ -8,6 +9,11 @@ import { canSeeChangeForUser, getPollEmitter, POLL_CHANGE_EVENT, } from "./poll.
8
9
  * server process. The regular /poll endpoint remains the cross-process and
9
10
  * serverless cold-start fallback because it can detect DB timestamp changes
10
11
  * even when the write happened somewhere this EventEmitter could not see.
12
+ *
13
+ * Also forwards awareness change events (cursor/presence updates) so
14
+ * connected peers receive cursor moves push-style instead of waiting for
15
+ * the next poll cycle. Polling fallback keeps working — cursors degrade
16
+ * gracefully to poll cadence without SSE.
11
17
  */
12
18
  export function createPollEventsHandler() {
13
19
  return defineEventHandler(async (event) => {
@@ -18,22 +24,39 @@ export function createPollEventsHandler() {
18
24
  }
19
25
  const stream = createEventStream(event);
20
26
  let closed = false;
21
- const push = (change) => {
27
+ const safePush = (data) => {
22
28
  if (closed)
23
29
  return;
24
- if (!canSeeChangeForUser(change, session.email, session.orgId))
25
- return;
26
30
  try {
27
- stream.push(JSON.stringify(change));
31
+ stream.push(data);
28
32
  }
29
33
  catch {
30
34
  // EventSource will reconnect; /poll catches anything missed.
31
35
  }
32
36
  };
37
+ const push = (change) => {
38
+ if (closed)
39
+ return;
40
+ if (!canSeeChangeForUser(change, session.email, session.orgId))
41
+ return;
42
+ safePush(JSON.stringify(change));
43
+ };
44
+ // Awareness fast-path: forward cursor/presence events immediately.
45
+ // No ring-buffer needed — clients reconcile on the next poll if SSE is down.
46
+ const pushAwareness = (change) => {
47
+ if (closed)
48
+ return;
49
+ // Respect org scoping if present.
50
+ if (change.orgId && session.orgId && change.orgId !== session.orgId)
51
+ return;
52
+ safePush(JSON.stringify(change));
53
+ };
33
54
  getPollEmitter().on(POLL_CHANGE_EVENT, push);
55
+ getAwarenessEmitter().on(AWARENESS_CHANGE_EVENT, pushAwareness);
34
56
  stream.onClosed(() => {
35
57
  closed = true;
36
58
  getPollEmitter().off(POLL_CHANGE_EVENT, push);
59
+ getAwarenessEmitter().off(AWARENESS_CHANGE_EVENT, pushAwareness);
37
60
  });
38
61
  return stream.send();
39
62
  });
@@ -1 +1 @@
1
- {"version":3,"file":"poll-events.js","sourceRoot":"","sources":["../../src/server/poll-events.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,IAAI,CAAC;AAC9E,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACvC,OAAO,EACL,mBAAmB,EACnB,cAAc,EACd,iBAAiB,GAElB,MAAM,WAAW,CAAC;AAEnB;;;;;;;GAOG;AACH,MAAM,UAAU,uBAAuB;IACrC,OAAO,kBAAkB,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE;QACxC,MAAM,OAAO,GAAG,MAAM,UAAU,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;QAC1D,IAAI,CAAC,OAAO,EAAE,KAAK,EAAE,CAAC;YACpB,iBAAiB,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;YAC9B,OAAO,EAAE,KAAK,EAAE,iBAAiB,EAAE,CAAC;QACtC,CAAC;QAED,MAAM,MAAM,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAC;QACxC,IAAI,MAAM,GAAG,KAAK,CAAC;QAEnB,MAAM,IAAI,GAAG,CAAC,MAAmB,EAAE,EAAE;YACnC,IAAI,MAAM;gBAAE,OAAO;YACnB,IAAI,CAAC,mBAAmB,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,KAAK,CAAC;gBAAE,OAAO;YACvE,IAAI,CAAC;gBACH,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC;YACtC,CAAC;YAAC,MAAM,CAAC;gBACP,6DAA6D;YAC/D,CAAC;QACH,CAAC,CAAC;QAEF,cAAc,EAAE,CAAC,EAAE,CAAC,iBAAiB,EAAE,IAAI,CAAC,CAAC;QAE7C,MAAM,CAAC,QAAQ,CAAC,GAAG,EAAE;YACnB,MAAM,GAAG,IAAI,CAAC;YACd,cAAc,EAAE,CAAC,GAAG,CAAC,iBAAiB,EAAE,IAAI,CAAC,CAAC;QAChD,CAAC,CAAC,CAAC;QAEH,OAAO,MAAM,CAAC,IAAI,EAAE,CAAC;IACvB,CAAC,CAAC,CAAC;AACL,CAAC","sourcesContent":["import { createEventStream, defineEventHandler, setResponseStatus } from \"h3\";\nimport { getSession } from \"./auth.js\";\nimport {\n canSeeChangeForUser,\n getPollEmitter,\n POLL_CHANGE_EVENT,\n type ChangeEvent,\n} from \"./poll.js\";\n\n/**\n * Stream in-process poll events over SSE.\n *\n * This is the fast path for agent/tool/action writes that happen in the same\n * server process. The regular /poll endpoint remains the cross-process and\n * serverless cold-start fallback because it can detect DB timestamp changes\n * even when the write happened somewhere this EventEmitter could not see.\n */\nexport function createPollEventsHandler() {\n return defineEventHandler(async (event) => {\n const session = await getSession(event).catch(() => null);\n if (!session?.email) {\n setResponseStatus(event, 401);\n return { error: \"Unauthenticated\" };\n }\n\n const stream = createEventStream(event);\n let closed = false;\n\n const push = (change: ChangeEvent) => {\n if (closed) return;\n if (!canSeeChangeForUser(change, session.email, session.orgId)) return;\n try {\n stream.push(JSON.stringify(change));\n } catch {\n // EventSource will reconnect; /poll catches anything missed.\n }\n };\n\n getPollEmitter().on(POLL_CHANGE_EVENT, push);\n\n stream.onClosed(() => {\n closed = true;\n getPollEmitter().off(POLL_CHANGE_EVENT, push);\n });\n\n return stream.send();\n });\n}\n"]}
1
+ {"version":3,"file":"poll-events.js","sourceRoot":"","sources":["../../src/server/poll-events.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,MAAM,IAAI,CAAC;AAC9E,OAAO,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACvC,OAAO,EACL,mBAAmB,EACnB,cAAc,EACd,iBAAiB,GAElB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,mBAAmB,EACnB,sBAAsB,GAEvB,MAAM,wBAAwB,CAAC;AAEhC;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,uBAAuB;IACrC,OAAO,kBAAkB,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE;QACxC,MAAM,OAAO,GAAG,MAAM,UAAU,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;QAC1D,IAAI,CAAC,OAAO,EAAE,KAAK,EAAE,CAAC;YACpB,iBAAiB,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;YAC9B,OAAO,EAAE,KAAK,EAAE,iBAAiB,EAAE,CAAC;QACtC,CAAC;QAED,MAAM,MAAM,GAAG,iBAAiB,CAAC,KAAK,CAAC,CAAC;QACxC,IAAI,MAAM,GAAG,KAAK,CAAC;QAEnB,MAAM,QAAQ,GAAG,CAAC,IAAY,EAAE,EAAE;YAChC,IAAI,MAAM;gBAAE,OAAO;YACnB,IAAI,CAAC;gBACH,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACpB,CAAC;YAAC,MAAM,CAAC;gBACP,6DAA6D;YAC/D,CAAC;QACH,CAAC,CAAC;QAEF,MAAM,IAAI,GAAG,CAAC,MAAmB,EAAE,EAAE;YACnC,IAAI,MAAM;gBAAE,OAAO;YACnB,IAAI,CAAC,mBAAmB,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,EAAE,OAAO,CAAC,KAAK,CAAC;gBAAE,OAAO;YACvE,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC;QACnC,CAAC,CAAC;QAEF,mEAAmE;QACnE,6EAA6E;QAC7E,MAAM,aAAa,GAAG,CAAC,MAA4B,EAAE,EAAE;YACrD,IAAI,MAAM;gBAAE,OAAO;YACnB,kCAAkC;YAClC,IAAI,MAAM,CAAC,KAAK,IAAI,OAAO,CAAC,KAAK,IAAI,MAAM,CAAC,KAAK,KAAK,OAAO,CAAC,KAAK;gBACjE,OAAO;YACT,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC;QACnC,CAAC,CAAC;QAEF,cAAc,EAAE,CAAC,EAAE,CAAC,iBAAiB,EAAE,IAAI,CAAC,CAAC;QAC7C,mBAAmB,EAAE,CAAC,EAAE,CAAC,sBAAsB,EAAE,aAAa,CAAC,CAAC;QAEhE,MAAM,CAAC,QAAQ,CAAC,GAAG,EAAE;YACnB,MAAM,GAAG,IAAI,CAAC;YACd,cAAc,EAAE,CAAC,GAAG,CAAC,iBAAiB,EAAE,IAAI,CAAC,CAAC;YAC9C,mBAAmB,EAAE,CAAC,GAAG,CAAC,sBAAsB,EAAE,aAAa,CAAC,CAAC;QACnE,CAAC,CAAC,CAAC;QAEH,OAAO,MAAM,CAAC,IAAI,EAAE,CAAC;IACvB,CAAC,CAAC,CAAC;AACL,CAAC","sourcesContent":["import { createEventStream, defineEventHandler, setResponseStatus } from \"h3\";\nimport { getSession } from \"./auth.js\";\nimport {\n canSeeChangeForUser,\n getPollEmitter,\n POLL_CHANGE_EVENT,\n type ChangeEvent,\n} from \"./poll.js\";\nimport {\n getAwarenessEmitter,\n AWARENESS_CHANGE_EVENT,\n type AwarenessChangeEvent,\n} from \"../collab/awareness.js\";\n\n/**\n * Stream in-process poll events over SSE.\n *\n * This is the fast path for agent/tool/action writes that happen in the same\n * server process. The regular /poll endpoint remains the cross-process and\n * serverless cold-start fallback because it can detect DB timestamp changes\n * even when the write happened somewhere this EventEmitter could not see.\n *\n * Also forwards awareness change events (cursor/presence updates) so\n * connected peers receive cursor moves push-style instead of waiting for\n * the next poll cycle. Polling fallback keeps working — cursors degrade\n * gracefully to poll cadence without SSE.\n */\nexport function createPollEventsHandler() {\n return defineEventHandler(async (event) => {\n const session = await getSession(event).catch(() => null);\n if (!session?.email) {\n setResponseStatus(event, 401);\n return { error: \"Unauthenticated\" };\n }\n\n const stream = createEventStream(event);\n let closed = false;\n\n const safePush = (data: string) => {\n if (closed) return;\n try {\n stream.push(data);\n } catch {\n // EventSource will reconnect; /poll catches anything missed.\n }\n };\n\n const push = (change: ChangeEvent) => {\n if (closed) return;\n if (!canSeeChangeForUser(change, session.email, session.orgId)) return;\n safePush(JSON.stringify(change));\n };\n\n // Awareness fast-path: forward cursor/presence events immediately.\n // No ring-buffer needed — clients reconcile on the next poll if SSE is down.\n const pushAwareness = (change: AwarenessChangeEvent) => {\n if (closed) return;\n // Respect org scoping if present.\n if (change.orgId && session.orgId && change.orgId !== session.orgId)\n return;\n safePush(JSON.stringify(change));\n };\n\n getPollEmitter().on(POLL_CHANGE_EVENT, push);\n getAwarenessEmitter().on(AWARENESS_CHANGE_EVENT, pushAwareness);\n\n stream.onClosed(() => {\n closed = true;\n getPollEmitter().off(POLL_CHANGE_EVENT, push);\n getAwarenessEmitter().off(AWARENESS_CHANGE_EVENT, pushAwareness);\n });\n\n return stream.send();\n });\n}\n"]}