ocean-brain 0.14.0 → 0.15.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 (84) hide show
  1. package/package.json +2 -1
  2. package/server/client/dist/assets/ArrowClockwise.es-BwtKWcmj.js +1 -0
  3. package/server/client/dist/assets/{ArrowRight.es-nBJws3ws.js → ArrowRight.es-E09Lfefr.js} +1 -1
  4. package/server/client/dist/assets/{AuxiliaryPanelHeader-BN_2axQu.js → AuxiliaryPanelHeader-Bys98BJJ.js} +1 -1
  5. package/server/client/dist/assets/Calendar-BOhJYi8K.js +25 -0
  6. package/server/client/dist/assets/Callout-CAH1ua9B.js +1 -0
  7. package/server/client/dist/assets/{CaretUp.es-DDbqKn78.js → CaretUp.es-DSWjIa-J.js} +1 -1
  8. package/server/client/dist/assets/{CheckCircle.es-CyY9aLBF.js → CheckCircle.es-Cou6rItm.js} +1 -1
  9. package/server/client/dist/assets/{Copy.es-BwX4tEoU.js → Copy.es-ftEldGIL.js} +1 -1
  10. package/server/client/dist/assets/{File.es-DeTgvUnI.js → File.es-DlKAX1uO.js} +1 -1
  11. package/server/client/dist/assets/{FileText.es-C-bpw8dQ.js → FileText.es-D6JM_MqM.js} +1 -1
  12. package/server/client/dist/assets/{FlowArrow.es-CKcMBOmX.js → FlowArrow.es-BRWXqf9A.js} +1 -1
  13. package/server/client/dist/assets/{Graph-BNoSRJP3.js → Graph-BaqGi3M7.js} +1 -1
  14. package/server/client/dist/assets/{Graph-DYYxNAWn.js → Graph-L-d0cdMk.js} +1 -1
  15. package/server/client/dist/assets/{Image.es-d4jWnijM.js → Image.es-Bh2mTNtr.js} +1 -1
  16. package/server/client/dist/assets/{Info.es-DVzN2joA.js → Info.es-Bwz4CSvS.js} +1 -1
  17. package/server/client/dist/assets/Integration-C-rva2X9.js +1 -0
  18. package/server/client/dist/assets/{LinkSimple.es-BD4aGTc3.js → LinkSimple.es-iJvLf-MB.js} +1 -1
  19. package/server/client/dist/assets/{Note-FL31jb2U.js → Note-OO_uh2a7.js} +4 -4
  20. package/server/client/dist/assets/{NotePropertiesPanel-os22Ppnu.js → NotePropertiesPanel-BFWjxL_w.js} +1 -1
  21. package/server/client/dist/assets/{Plus.es-Cl8zOOZa.js → Plus.es-Dmb3oijD.js} +1 -1
  22. package/server/client/dist/assets/Reminders-CtpFtvu6.js +1 -0
  23. package/server/client/dist/assets/{Search-CPPTW2PO.js → Search-t48pkUg-.js} +2 -2
  24. package/server/client/dist/assets/{SurfaceCard-jL2qzrzF.js → SurfaceCard-zdJ8k1o0.js} +1 -1
  25. package/server/client/dist/assets/{Tag-3DbGmuxa.js → Tag-BL2h3ZaX.js} +1 -1
  26. package/server/client/dist/assets/TagNotes-BpRTCEBf.js +1 -0
  27. package/server/client/dist/assets/{Trash.es-fxukjAO6.js → Trash.es-DFnO8iF5.js} +1 -1
  28. package/server/client/dist/assets/{ViewChip-CVe_HNyd.js → ViewChip-BdPhSEvG.js} +1 -1
  29. package/server/client/dist/assets/Views-BoO6vgnF.js +1 -0
  30. package/server/client/dist/assets/WarningCircle.es-BAvgGuQK.js +1 -0
  31. package/server/client/dist/assets/app-OGsLwSqJ.js +151 -0
  32. package/server/client/dist/assets/app-yYMsgUrz.css +1 -0
  33. package/server/client/dist/assets/{calendar-data-BQ24n348.js → calendar-data-CwVF7Pz3.js} +1 -1
  34. package/server/client/dist/assets/{image.api-CyIIEGkG.js → image.api-CHuhmq_G.js} +1 -1
  35. package/server/client/dist/assets/index-CuCzOHJ6.js +1 -0
  36. package/server/client/dist/assets/integrations-B5SIEnG_.js +1 -0
  37. package/server/client/dist/assets/{manage-image-DXedfr7U.js → manage-image-Kd-7IGfB.js} +1 -1
  38. package/server/client/dist/assets/{manage-image-detail-DmifOPNU.js → manage-image-detail-HU0QLP-t.js} +1 -1
  39. package/server/client/dist/assets/mcp-BQ418pyx.js +5 -0
  40. package/server/client/dist/assets/{mcp-admin-adapter-CmKsg1vd.js → mcp-admin-adapter-CUakQ_Xm.js} +1 -1
  41. package/server/client/dist/assets/{note-ui-DRnAnwKv.js → note-ui-CRcP5BjO.js} +1 -1
  42. package/server/client/dist/assets/{placeholder-G6iEBbDy.js → placeholder-D7JKnpuh.js} +1 -1
  43. package/server/client/dist/assets/{properties-BPobx_Qk.js → properties-CtagYHMr.js} +1 -1
  44. package/server/client/dist/assets/route-preload.js +2 -2
  45. package/server/client/dist/assets/search-B5tCkhwj.js +1 -0
  46. package/server/client/dist/assets/{search-admin-adapter-7sTcWTmv.js → search-admin-adapter-Be9xSY88.js} +1 -1
  47. package/server/client/dist/assets/{tag.api-kKZl04yY.js → tag.api-DGfi8hWL.js} +1 -1
  48. package/server/client/dist/assets/{time-BwwQGbK-.js → time-Kh6oav5R.js} +9 -9
  49. package/server/client/dist/assets/{trash-Cepvi7G8.js → trash-Di2BWyUO.js} +1 -1
  50. package/server/client/dist/assets/{useNoteMutate-D-mZKXjl.js → useNoteMutate-BQj6jHhV.js} +1 -1
  51. package/server/client/dist/assets/{useReminderMutate-Cexk1ydX.js → useReminderMutate-jubqwSD9.js} +1 -1
  52. package/server/client/dist/index.html +5 -5
  53. package/server/dist/app.js +9 -1
  54. package/server/dist/features/app-gateway/access.js +73 -0
  55. package/server/dist/features/app-gateway/database.js +20 -0
  56. package/server/dist/features/app-gateway/gateway.js +208 -0
  57. package/server/dist/features/integration/access-events.js +17 -0
  58. package/server/dist/features/integration/manifest.js +31 -2
  59. package/server/dist/features/integration/note-sync.js +156 -0
  60. package/server/dist/features/integration/service.js +80 -7
  61. package/server/dist/features/integration/status-report.js +22 -0
  62. package/server/dist/features/note/graphql/note.mutation.resolver.js +20 -12
  63. package/server/dist/features/note/graphql/note.query.resolver.js +4 -1
  64. package/server/dist/features/note/services/change-events.js +26 -0
  65. package/server/dist/features/search/search-manager.js +4 -7
  66. package/server/dist/modules/server-events.js +3 -0
  67. package/server/dist/routes/apps.js +163 -0
  68. package/server/dist/routes/index.js +2 -0
  69. package/server/dist/routes/integrations.js +21 -1
  70. package/server/prisma/migrations/20260919090000_0022_integration_proxy_url/migration.sql +2 -0
  71. package/server/prisma/schema.prisma +2 -0
  72. package/server/client/dist/assets/Calendar-vA4Xa4Qr.js +0 -25
  73. package/server/client/dist/assets/Callout-Bo2P960P.js +0 -1
  74. package/server/client/dist/assets/Integration-gDDtIJYJ.js +0 -1
  75. package/server/client/dist/assets/Reminders-Dt2JuGci.js +0 -1
  76. package/server/client/dist/assets/TagNotes-DLwRF8pT.js +0 -1
  77. package/server/client/dist/assets/Views-8ghLRax7.js +0 -1
  78. package/server/client/dist/assets/app-Bcl0U7r9.css +0 -1
  79. package/server/client/dist/assets/app-DzChh-V0.js +0 -151
  80. package/server/client/dist/assets/index-Iy_BgP1R.js +0 -1
  81. package/server/client/dist/assets/integrations-LOdTs1T6.js +0 -1
  82. package/server/client/dist/assets/mcp-d8IzsoRz.js +0 -5
  83. package/server/client/dist/assets/search-DanMKCC8.js +0 -1
  84. package/server/dist/features/search/note-change.js +0 -16
@@ -0,0 +1,156 @@
1
+ import { createHash } from "node:crypto";
2
+ import { subscribeNoteChanges } from "../../features/note/services/change-events.js";
3
+ import models from "../../models.js";
4
+ import { createAppError } from "../../modules/error-handler.js";
5
+ import { subscribeIntegrationAccessChanges } from "./access-events.js";
6
+ const DEFAULT_PAGE_SIZE = 100;
7
+ const MAX_PAGE_SIZE = 500;
8
+ const KEEP_ALIVE_INTERVAL_MS = 3e4;
9
+ const MAX_STREAMS_PER_CONNECTION = 2;
10
+ const MAX_NOTE_ID = 2147483647;
11
+ const activeStreamsByConnection = /* @__PURE__ */ new Map();
12
+ const resolvePageSize = (value) => {
13
+ if (value === void 0) return DEFAULT_PAGE_SIZE;
14
+ if (!Number.isInteger(value) || Number(value) < 1 || Number(value) > MAX_PAGE_SIZE) {
15
+ throw createAppError(
16
+ 400,
17
+ "INVALID_NOTE_CATALOG_PAGE_SIZE",
18
+ `limit must be an integer between 1 and ${MAX_PAGE_SIZE}.`
19
+ );
20
+ }
21
+ return Number(value);
22
+ };
23
+ const resolveAfterId = (value) => {
24
+ if (value === void 0) return 0;
25
+ const normalized = typeof value === "number" && Number.isSafeInteger(value) ? String(value) : value;
26
+ if (typeof normalized !== "string" || !/^(0|[1-9]\d*)$/.test(normalized)) {
27
+ throw createAppError(400, "INVALID_NOTE_CATALOG_CURSOR", "afterId must be a nonnegative integer.");
28
+ }
29
+ const parsed = Number(normalized);
30
+ if (!Number.isSafeInteger(parsed) || parsed > MAX_NOTE_ID) {
31
+ throw createAppError(400, "INVALID_NOTE_CATALOG_CURSOR", "afterId exceeds the supported range.");
32
+ }
33
+ return parsed;
34
+ };
35
+ const createIntegrationNoteCatalogHandler = () => {
36
+ return async (request, reply) => {
37
+ const limit = resolvePageSize(request.body?.limit);
38
+ const afterId = resolveAfterId(request.body?.afterId);
39
+ const [rows, propertySchema] = await Promise.all([
40
+ models.note.findMany({
41
+ where: { id: { gt: afterId } },
42
+ orderBy: { id: "asc" },
43
+ take: limit + 1,
44
+ select: { id: true, updatedAt: true }
45
+ }),
46
+ afterId === 0 ? models.propertyDefinition.findMany({
47
+ orderBy: { key: "asc" },
48
+ select: {
49
+ key: true,
50
+ name: true,
51
+ valueType: true,
52
+ updatedAt: true,
53
+ options: {
54
+ orderBy: [{ order: "asc" }, { id: "asc" }],
55
+ select: {
56
+ id: true,
57
+ label: true,
58
+ value: true,
59
+ color: true,
60
+ order: true,
61
+ updatedAt: true
62
+ }
63
+ }
64
+ }
65
+ }) : Promise.resolve(null)
66
+ ]);
67
+ const hasMore = rows.length > limit;
68
+ const page = hasMore ? rows.slice(0, limit) : rows;
69
+ return reply.status(200).send({
70
+ notes: page.map((note) => ({ id: String(note.id), updatedAt: note.updatedAt.toISOString() })),
71
+ ...propertySchema ? { propertySchemaHash: createHash("sha256").update(JSON.stringify(propertySchema)).digest("hex") } : {},
72
+ hasMore,
73
+ nextAfterId: hasMore ? String(page.at(-1)?.id) : null
74
+ });
75
+ };
76
+ };
77
+ const serializeIntegrationNoteEvent = (event) => {
78
+ const data = {
79
+ type: event.type,
80
+ noteId: String(event.noteId),
81
+ occurredAt: event.occurredAt
82
+ };
83
+ return `event: ${event.type}
84
+ data: ${JSON.stringify(data)}
85
+
86
+ `;
87
+ };
88
+ const createIntegrationNoteEventsHandler = (subscribe = subscribeNoteChanges) => {
89
+ return async (request, reply) => {
90
+ const connectionId = request.integration?.connectionId;
91
+ if (!connectionId) {
92
+ throw createAppError(401, "UNAUTHORIZED", "An authenticated integration connection is required.");
93
+ }
94
+ const activeStreamCount = activeStreamsByConnection.get(connectionId) ?? 0;
95
+ if (activeStreamCount >= MAX_STREAMS_PER_CONNECTION) {
96
+ throw createAppError(
97
+ 429,
98
+ "INTEGRATION_EVENT_STREAM_LIMIT",
99
+ `A connection can open at most ${MAX_STREAMS_PER_CONNECTION} event streams.`
100
+ );
101
+ }
102
+ reply.hijack();
103
+ const response = reply.raw;
104
+ response.statusCode = 200;
105
+ response.setHeader("Content-Type", "text/event-stream; charset=utf-8");
106
+ response.setHeader("Cache-Control", "no-cache, no-transform");
107
+ response.setHeader("Connection", "keep-alive");
108
+ response.setHeader("X-Accel-Buffering", "no");
109
+ response.flushHeaders();
110
+ response.write(": connected\n\n");
111
+ activeStreamsByConnection.set(connectionId, activeStreamCount + 1);
112
+ let cleanedUp = false;
113
+ let keepAliveTimer = null;
114
+ let unsubscribeNoteChanges = () => void 0;
115
+ let unsubscribeAccessChanges = () => void 0;
116
+ const cleanup = () => {
117
+ if (cleanedUp) return;
118
+ cleanedUp = true;
119
+ if (keepAliveTimer) clearInterval(keepAliveTimer);
120
+ unsubscribeNoteChanges();
121
+ unsubscribeAccessChanges();
122
+ const remainingStreams = (activeStreamsByConnection.get(connectionId) ?? 1) - 1;
123
+ if (remainingStreams > 0) activeStreamsByConnection.set(connectionId, remainingStreams);
124
+ else activeStreamsByConnection.delete(connectionId);
125
+ };
126
+ const endStream = () => {
127
+ cleanup();
128
+ if (!response.writableEnded) {
129
+ try {
130
+ response.end();
131
+ } catch {
132
+ }
133
+ }
134
+ };
135
+ const write = (chunk) => {
136
+ try {
137
+ if (!response.write(chunk)) endStream();
138
+ } catch {
139
+ endStream();
140
+ }
141
+ };
142
+ unsubscribeNoteChanges = subscribe((event) => write(serializeIntegrationNoteEvent(event)));
143
+ unsubscribeAccessChanges = subscribeIntegrationAccessChanges((changedConnectionId) => {
144
+ if (changedConnectionId === connectionId) endStream();
145
+ });
146
+ keepAliveTimer = setInterval(() => write(": keepalive\n\n"), KEEP_ALIVE_INTERVAL_MS);
147
+ keepAliveTimer.unref?.();
148
+ request.raw.on("close", cleanup);
149
+ response.on("close", cleanup);
150
+ };
151
+ };
152
+ export {
153
+ createIntegrationNoteCatalogHandler,
154
+ createIntegrationNoteEventsHandler,
155
+ serializeIntegrationNoteEvent
156
+ };
@@ -2,13 +2,16 @@ import { createHash } from "node:crypto";
2
2
  import models from "../../models.js";
3
3
  import { createAppError } from "../../modules/error-handler.js";
4
4
  import { issueIntegrationToken } from "../../modules/integration-token.js";
5
+ import { emitIntegrationAccessChanged } from "./access-events.js";
5
6
  import {
6
7
  MCP_CONNECTION_ID,
7
8
  NATIVE_INTEGRATIONS,
9
+ parseIntegrationProxyUrl,
8
10
  parseManifest,
9
11
  parsePermissions,
10
12
  validateGrants
11
13
  } from "./manifest.js";
14
+ import { parseStatusUpdate, readStatusReport } from "./status-report.js";
12
15
  const resolveManifest = (row) => {
13
16
  if (row.native) {
14
17
  const definition = NATIVE_INTEGRATIONS.find((integration) => integration.connectionId === row.id);
@@ -20,18 +23,31 @@ const toConnection = (row) => ({
20
23
  id: row.id,
21
24
  native: row.native,
22
25
  manifest: resolveManifest(row),
26
+ proxyConfigured: row.proxyUrl !== null,
23
27
  grantedPermissions: parsePermissions(JSON.parse(row.grantedPermissions)),
24
28
  enabled: row.enabled,
25
29
  pinned: row.pinned,
26
30
  createdAt: row.createdAt.toISOString(),
27
31
  updatedAt: row.updatedAt.toISOString(),
32
+ statusReport: readStatusReport(row.credential?.statusReport ?? null),
28
33
  token: row.credential ? {
29
34
  id: row.credential.id,
30
35
  createdAt: row.credential.createdAt.toISOString(),
31
36
  lastUsedAt: row.credential.lastUsedAt?.toISOString() ?? null
32
37
  } : null
33
38
  });
34
- const credentialSummary = { select: { id: true, createdAt: true, lastUsedAt: true } };
39
+ const credentialSummary = { select: { id: true, createdAt: true, lastUsedAt: true, statusReport: true } };
40
+ const parseConnectionProxyUrl = (manifest, value) => {
41
+ if (manifest.launch?.mode === "proxied") return parseIntegrationProxyUrl(value);
42
+ if (value !== void 0) {
43
+ throw createAppError(
44
+ 400,
45
+ "UNEXPECTED_INTEGRATION_PROXY_URL",
46
+ "A private app URL can only be configured for proxied launches."
47
+ );
48
+ }
49
+ return null;
50
+ };
35
51
  const createIntegrationService = (db = models) => {
36
52
  const get = async (id) => {
37
53
  const row = await db.integrationConnection.findUnique({
@@ -75,10 +91,12 @@ const createIntegrationService = (db = models) => {
75
91
  "The ocean-brain namespace is reserved for native integrations."
76
92
  );
77
93
  const grantedPermissions = validateGrants(input.grantedPermissions ?? [], manifest);
94
+ const proxyUrl = parseConnectionProxyUrl(manifest, input.proxyUrl);
78
95
  const row = await db.integrationConnection.create({
79
96
  data: {
80
97
  integrationId: manifest.id,
81
98
  manifest: JSON.stringify(manifest),
99
+ proxyUrl,
82
100
  grantedPermissions: JSON.stringify(grantedPermissions)
83
101
  },
84
102
  include: { credential: credentialSummary }
@@ -86,7 +104,7 @@ const createIntegrationService = (db = models) => {
86
104
  return toConnection(row);
87
105
  },
88
106
  async update(id, input) {
89
- return db.$transaction(async (tx) => {
107
+ const connection = await db.$transaction(async (tx) => {
90
108
  const current = await tx.integrationConnection.findUnique({ where: { id } });
91
109
  if (!current)
92
110
  throw createAppError(404, "INTEGRATION_NOT_FOUND", "The integration connection was not found.");
@@ -96,21 +114,50 @@ const createIntegrationService = (db = models) => {
96
114
  "NATIVE_INTEGRATION_MANIFEST",
97
115
  "Native integration manifests are managed by Ocean Brain."
98
116
  );
99
- const manifest = input.manifest === void 0 ? resolveManifest(current) : parseManifest(input.manifest);
117
+ for (const key of ["enabled", "pinned"]) {
118
+ if (input[key] !== void 0 && typeof input[key] !== "boolean")
119
+ throw createAppError(400, "INVALID_INTEGRATION_SETTING", `${key} must be a boolean.`);
120
+ }
121
+ const currentManifest = resolveManifest(current);
122
+ const manifest = input.manifest === void 0 ? currentManifest : parseManifest(input.manifest);
100
123
  if (manifest.id !== current.integrationId)
101
124
  throw createAppError(400, "INTEGRATION_ID_CHANGED", "An upgrade cannot change the integration id.");
102
125
  const previousGrants = parsePermissions(JSON.parse(current.grantedPermissions)).filter(
103
126
  (permission) => manifest.permissions.includes(permission)
104
127
  );
105
128
  const grantedPermissions = validateGrants(input.grantedPermissions ?? previousGrants, manifest);
106
- for (const key of ["enabled", "pinned"]) {
107
- if (input[key] !== void 0 && typeof input[key] !== "boolean")
108
- throw createAppError(400, "INVALID_INTEGRATION_SETTING", `${key} must be a boolean.`);
129
+ let proxyUrl = current.proxyUrl;
130
+ if (manifest.launch?.mode === "proxied") {
131
+ if (input.proxyUrl !== void 0) proxyUrl = parseIntegrationProxyUrl(input.proxyUrl);
132
+ const enteringProxiedMode = input.manifest !== void 0 && currentManifest.launch?.mode !== "proxied";
133
+ if ((enteringProxiedMode || input.enabled === true) && !proxyUrl) {
134
+ throw createAppError(
135
+ 400,
136
+ "INTEGRATION_PROXY_URL_REQUIRED",
137
+ "Configure a private app URL before enabling a proxied integration."
138
+ );
139
+ }
140
+ } else {
141
+ if (input.proxyUrl !== void 0) {
142
+ throw createAppError(
143
+ 400,
144
+ "UNEXPECTED_INTEGRATION_PROXY_URL",
145
+ "A private app URL can only be configured for proxied launches."
146
+ );
147
+ }
148
+ proxyUrl = null;
149
+ }
150
+ if (input.enabled !== void 0 || input.grantedPermissions !== void 0 || input.manifest !== void 0 || input.proxyUrl !== void 0) {
151
+ await tx.integrationCredential.updateMany({
152
+ where: { connectionId: id },
153
+ data: { statusReport: null }
154
+ });
109
155
  }
110
156
  const row = await tx.integrationConnection.update({
111
157
  where: { id },
112
158
  data: {
113
159
  manifest: JSON.stringify(manifest),
160
+ proxyUrl,
114
161
  grantedPermissions: JSON.stringify(grantedPermissions),
115
162
  ...typeof input.enabled === "boolean" ? { enabled: input.enabled } : {},
116
163
  ...typeof input.pinned === "boolean" ? { pinned: input.pinned } : {}
@@ -119,6 +166,10 @@ const createIntegrationService = (db = models) => {
119
166
  });
120
167
  return toConnection(row);
121
168
  });
169
+ if (input.enabled !== void 0 || input.grantedPermissions !== void 0 || input.manifest !== void 0) {
170
+ emitIntegrationAccessChanged(id);
171
+ }
172
+ return connection;
122
173
  },
123
174
  async disconnect(id) {
124
175
  const connection = await get(id);
@@ -129,6 +180,7 @@ const createIntegrationService = (db = models) => {
129
180
  "Native integrations can be disabled, but cannot be disconnected."
130
181
  );
131
182
  await db.integrationConnection.delete({ where: { id } });
183
+ emitIntegrationAccessChanged(id);
132
184
  },
133
185
  async rotateToken(id) {
134
186
  await get(id);
@@ -136,13 +188,34 @@ const createIntegrationService = (db = models) => {
136
188
  await db.integrationCredential.upsert({
137
189
  where: { connectionId: id },
138
190
  create: { connectionId: id, tokenHash: token.hash },
139
- update: { tokenHash: token.hash, createdAt: /* @__PURE__ */ new Date(), lastUsedAt: null }
191
+ update: { tokenHash: token.hash, createdAt: /* @__PURE__ */ new Date(), lastUsedAt: null, statusReport: null }
140
192
  });
193
+ emitIntegrationAccessChanged(id);
141
194
  return { token: token.plaintext };
142
195
  },
143
196
  async revokeToken(id) {
144
197
  await get(id);
145
198
  await db.integrationCredential.deleteMany({ where: { connectionId: id } });
199
+ emitIntegrationAccessChanged(id);
200
+ },
201
+ async reportStatus(token, input) {
202
+ const update = parseStatusUpdate(input);
203
+ const tokenHash = createHash("sha256").update(token, "utf8").digest("hex");
204
+ return db.$transaction(async (tx) => {
205
+ const credential = await tx.integrationCredential.findUnique({
206
+ where: { tokenHash },
207
+ include: { connection: true }
208
+ });
209
+ if (!credential?.connection.enabled || !parsePermissions(JSON.parse(credential.connection.grantedPermissions)).includes("notes:read") || !resolveManifest(credential.connection).permissions.includes("notes:read")) {
210
+ throw createAppError(403, "INTEGRATION_ACCESS_DENIED", "This connection cannot report activity.");
211
+ }
212
+ const report = { ...update, reportedAt: (/* @__PURE__ */ new Date()).toISOString() };
213
+ await tx.integrationCredential.update({
214
+ where: { tokenHash },
215
+ data: { statusReport: JSON.stringify(report) }
216
+ });
217
+ return report;
218
+ });
146
219
  },
147
220
  async authenticate(token) {
148
221
  const tokenHash = createHash("sha256").update(token, "utf8").digest("hex");
@@ -0,0 +1,22 @@
1
+ import { createAppError } from "../../modules/error-handler.js";
2
+ import { isRecord } from "./manifest.js";
3
+ function parseStatusUpdate(value) {
4
+ if (!isRecord(value) || value.state !== "running" && value.state !== "succeeded" && value.state !== "failed" || typeof value.message !== "string" || !value.message.trim() || value.message.length > 300 || [...value.message].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127)) {
5
+ throw createAppError(
6
+ 400,
7
+ "INVALID_INTEGRATION_STATUS",
8
+ "Provide a running, succeeded, or failed state and a message of 1\u2013300 characters without control characters."
9
+ );
10
+ }
11
+ return { state: value.state, message: value.message.trim() };
12
+ }
13
+ function readStatusReport(value) {
14
+ if (!value) return null;
15
+ const report = JSON.parse(value);
16
+ if (!isRecord(report) || typeof report.reportedAt !== "string") return null;
17
+ return { ...parseStatusUpdate(report), reportedAt: report.reportedAt };
18
+ }
19
+ export {
20
+ parseStatusUpdate,
21
+ readStatusReport
22
+ };
@@ -1,4 +1,5 @@
1
1
  import { GraphQLError } from "graphql";
2
+ import { emitNoteChange } from "../../../features/note/services/change-events.js";
2
3
  import { extractBlocksByType, parseNoteContent } from "../../../features/note/services/content-blocks.js";
3
4
  import { replaceNoteReferences } from "../../../features/note/services/note-reference-index.js";
4
5
  import {
@@ -18,7 +19,6 @@ import {
18
19
  isMissingNoteVersionError,
19
20
  isNoteVersionConflictError
20
21
  } from "../../../features/note/services/write-conflict.js";
21
- import { notifySemanticSearchNoteChanged } from "../../../features/search/note-change.js";
22
22
  import models from "../../../models.js";
23
23
  const PLACEHOLDER_PREFIX = "{%";
24
24
  const PLACEHOLDER_SUFFIX = "%}";
@@ -83,7 +83,7 @@ const noteMutationResolvers = {
83
83
  await replaceNoteReferences(tx, createdNote.id, replacedContent);
84
84
  return noteResult2;
85
85
  });
86
- notifySemanticSearchNoteChanged(noteResult.id);
86
+ emitNoteChange({ type: "note.created", noteId: noteResult.id });
87
87
  return noteResult;
88
88
  },
89
89
  updateNote: async (_, {
@@ -112,7 +112,7 @@ const noteMutationResolvers = {
112
112
  if (!updatedNote) {
113
113
  throw "NOT FOUND";
114
114
  }
115
- notifySemanticSearchNoteChanged(updatedNote.id);
115
+ emitNoteChange({ type: "note.updated", noteId: updatedNote.id });
116
116
  return updatedNote;
117
117
  } catch (error) {
118
118
  if (isNoteVersionConflictError(error)) {
@@ -146,7 +146,7 @@ const noteMutationResolvers = {
146
146
  if (!trashedNote) {
147
147
  throw "NOT FOUND";
148
148
  }
149
- notifySemanticSearchNoteChanged(Number(id));
149
+ emitNoteChange({ type: "note.deleted", noteId: Number(id) });
150
150
  return true;
151
151
  },
152
152
  restoreNoteSnapshot: async (_, { id, expectedUpdatedAt }, context) => {
@@ -158,7 +158,7 @@ const noteMutationResolvers = {
158
158
  if (!note) {
159
159
  throw "NOT FOUND";
160
160
  }
161
- notifySemanticSearchNoteChanged(note.id);
161
+ emitNoteChange({ type: "note.updated", noteId: note.id });
162
162
  return note;
163
163
  } catch (error) {
164
164
  if (isNoteVersionConflictError(error)) {
@@ -185,7 +185,7 @@ const noteMutationResolvers = {
185
185
  if (!note) {
186
186
  throw "NOT FOUND";
187
187
  }
188
- notifySemanticSearchNoteChanged(note.id);
188
+ emitNoteChange({ type: "note.created", noteId: note.id });
189
189
  return note;
190
190
  },
191
191
  purgeTrashedNote: async (_, { id }) => {
@@ -193,7 +193,6 @@ const noteMutationResolvers = {
193
193
  if (!purgedNote) {
194
194
  throw "NOT FOUND";
195
195
  }
196
- notifySemanticSearchNoteChanged(Number(id));
197
196
  return true;
198
197
  },
199
198
  createNotePropertyKey: async (_, { input }) => {
@@ -272,6 +271,7 @@ const noteMutationResolvers = {
272
271
  if (!note) {
273
272
  throw "NOT FOUND";
274
273
  }
274
+ emitNoteChange({ type: "note.updated", noteId: note.id, affectsSearchIndex: false });
275
275
  return note;
276
276
  } catch (error) {
277
277
  if (error instanceof InvalidNotePropertyInputError) {
@@ -307,10 +307,14 @@ const noteMutationResolvers = {
307
307
  throw error;
308
308
  }
309
309
  },
310
- pinNote: (_, { id, pinned }) => models.note.update({
311
- where: { id: Number(id) },
312
- data: { pinned: Boolean(pinned) }
313
- }),
310
+ pinNote: async (_, { id, pinned }) => {
311
+ const note = await models.note.update({
312
+ where: { id: Number(id) },
313
+ data: { pinned: Boolean(pinned) }
314
+ });
315
+ emitNoteChange({ type: "note.updated", noteId: note.id, affectsSearchIndex: false });
316
+ return note;
317
+ },
314
318
  reorderNotes: async (_, { notes }) => {
315
319
  const updatePromises = notes.map(
316
320
  ({ id, order }) => models.note.update({
@@ -318,7 +322,11 @@ const noteMutationResolvers = {
318
322
  data: { order }
319
323
  })
320
324
  );
321
- return Promise.all(updatePromises);
325
+ const updatedNotes = await Promise.all(updatePromises);
326
+ for (const note of updatedNotes) {
327
+ emitNoteChange({ type: "note.updated", noteId: note.id, affectsSearchIndex: false });
328
+ }
329
+ return updatedNotes;
322
330
  }
323
331
  };
324
332
  export {
@@ -1,3 +1,4 @@
1
+ import { emitNoteChange } from "../../../features/note/services/change-events.js";
1
2
  import { getNoteCleanupPreview, listNoteCleanupCandidates } from "../../../features/note/services/cleanup.js";
2
3
  import {
3
4
  buildNoteGraph,
@@ -293,7 +294,7 @@ const createNoteQueryResolver = (deps = {
293
294
  return note;
294
295
  }
295
296
  try {
296
- return await deps.updateNoteContent({
297
+ const updatedNote = await deps.updateNoteContent({
297
298
  id: note.id,
298
299
  updatedAt: note.updatedAt,
299
300
  content: newContent,
@@ -302,6 +303,8 @@ const createNoteQueryResolver = (deps = {
302
303
  content: newContent
303
304
  })
304
305
  });
306
+ emitNoteChange({ type: "note.updated", noteId: updatedNote.id });
307
+ return updatedNote;
305
308
  } catch (error) {
306
309
  if (deps.isRecordNotFoundError(error)) {
307
310
  return note;
@@ -0,0 +1,26 @@
1
+ import { EventEmitter } from "node:events";
2
+ const NOTE_CHANGE_CHANNEL = "note-change";
3
+ const noteChangeEmitter = new EventEmitter();
4
+ noteChangeEmitter.setMaxListeners(0);
5
+ const emitNoteChange = (input) => {
6
+ if (!Number.isInteger(input.noteId) || input.noteId <= 0) {
7
+ return null;
8
+ }
9
+ const event = {
10
+ ...input,
11
+ occurredAt: (/* @__PURE__ */ new Date()).toISOString(),
12
+ affectsSearchIndex: input.affectsSearchIndex ?? true
13
+ };
14
+ noteChangeEmitter.emit(NOTE_CHANGE_CHANNEL, event);
15
+ return event;
16
+ };
17
+ const subscribeNoteChanges = (listener) => {
18
+ noteChangeEmitter.on(NOTE_CHANGE_CHANNEL, listener);
19
+ return () => {
20
+ noteChangeEmitter.off(NOTE_CHANGE_CHANNEL, listener);
21
+ };
22
+ };
23
+ export {
24
+ emitNoteChange,
25
+ subscribeNoteChanges
26
+ };
@@ -1,5 +1,5 @@
1
+ import { subscribeNoteChanges } from "../../features/note/services/change-events.js";
1
2
  import models from "../../models.js";
2
- import { subscribeServerEvents } from "../../modules/server-events.js";
3
3
  import { paths } from "../../paths.js";
4
4
  import {
5
5
  createEmbeddingApiKeyFingerprint,
@@ -9,7 +9,6 @@ import {
9
9
  createOpenAiCompatibleEmbeddingClient,
10
10
  listOpenAiCompatibleEmbeddingModels
11
11
  } from "./embedding-client.js";
12
- import { subscribeSemanticSearchNoteChanges } from "./note-change.js";
13
12
  import {
14
13
  buildNoteEmbeddingChunks,
15
14
  NOTE_EMBEDDING_TEXT_SCHEMA_VERSION
@@ -432,11 +431,9 @@ const getDefaultSemanticSearchManager = () => {
432
431
  apiKeyStore: new FileEmbeddingApiKeyStore(paths.embeddingApiKey)
433
432
  });
434
433
  defaultSemanticSearchUnsubscribers = [
435
- subscribeServerEvents((event) => {
436
- void defaultSemanticSearchManager?.scheduleNoteSync(Number(event.noteId)).catch(() => void 0);
437
- }),
438
- subscribeSemanticSearchNoteChanges((noteId) => {
439
- void defaultSemanticSearchManager?.scheduleNoteSync(noteId).catch(() => void 0);
434
+ subscribeNoteChanges((event) => {
435
+ if (!event.affectsSearchIndex) return;
436
+ void defaultSemanticSearchManager?.scheduleNoteSync(event.noteId).catch(() => void 0);
440
437
  })
441
438
  ];
442
439
  defaultSemanticSearchManager.startBackgroundSync();
@@ -1,4 +1,5 @@
1
1
  import { EventEmitter } from "node:events";
2
+ import { emitNoteChange } from "../features/note/services/change-events.js";
2
3
  const SERVER_EVENT_CHANNEL = "server-event";
3
4
  const serverEventEmitter = new EventEmitter();
4
5
  serverEventEmitter.setMaxListeners(0);
@@ -9,6 +10,8 @@ const emitServerEvent = (event) => {
9
10
  ...event
10
11
  };
11
12
  serverEventEmitter.emit(SERVER_EVENT_CHANNEL, nextEvent);
13
+ const type = event.type.replace(/^(mcp|integration)\./, "");
14
+ emitNoteChange({ type, noteId: Number(event.noteId) });
12
15
  return nextEvent;
13
16
  };
14
17
  const subscribeServerEvents = (listener) => {