@bevel-software/platform-core-backend 0.28.0 → 0.29.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 (143) hide show
  1. package/agent-guide/access-control.md +15 -0
  2. package/agent-guide/skills.md +8 -0
  3. package/dist/core/catalog-revision.d.ts.map +1 -1
  4. package/dist/core/catalog-revision.js +4 -2
  5. package/dist/core/catalog-revision.js.map +1 -1
  6. package/dist/core/compose-embed.d.ts +35 -0
  7. package/dist/core/compose-embed.d.ts.map +1 -0
  8. package/dist/core/compose-embed.js +14 -0
  9. package/dist/core/compose-embed.js.map +1 -0
  10. package/dist/core/core-ports.d.ts +10 -0
  11. package/dist/core/core-ports.d.ts.map +1 -1
  12. package/dist/core/core-ports.js.map +1 -1
  13. package/dist/core/create-core-server.d.ts.map +1 -1
  14. package/dist/core/create-core-server.js +7 -1
  15. package/dist/core/create-core-server.js.map +1 -1
  16. package/dist/core/create-core-services.d.ts +1 -1
  17. package/dist/core/create-core-services.d.ts.map +1 -1
  18. package/dist/core/create-core-services.js +25 -14
  19. package/dist/core/create-core-services.js.map +1 -1
  20. package/dist/core/lifecycle.d.ts +11 -6
  21. package/dist/core/lifecycle.d.ts.map +1 -1
  22. package/dist/core/lifecycle.js +13 -6
  23. package/dist/core/lifecycle.js.map +1 -1
  24. package/dist/modules/access/access-control.interface.d.ts +5 -2
  25. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  26. package/dist/modules/access/access-control.service.d.ts +36 -14
  27. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  28. package/dist/modules/access/access-control.service.js +208 -85
  29. package/dist/modules/access/access-control.service.js.map +1 -1
  30. package/dist/modules/auth/index.d.ts +15 -0
  31. package/dist/modules/auth/index.d.ts.map +1 -0
  32. package/dist/modules/auth/index.js +20 -0
  33. package/dist/modules/auth/index.js.map +1 -0
  34. package/dist/modules/embed/embed.interface.d.ts +22 -0
  35. package/dist/modules/embed/embed.interface.d.ts.map +1 -1
  36. package/dist/modules/embed/embed.service.d.ts +28 -14
  37. package/dist/modules/embed/embed.service.d.ts.map +1 -1
  38. package/dist/modules/embed/embed.service.js +38 -17
  39. package/dist/modules/embed/embed.service.js.map +1 -1
  40. package/dist/modules/embed/embed.tools.d.ts.map +1 -1
  41. package/dist/modules/embed/embed.tools.js +10 -2
  42. package/dist/modules/embed/embed.tools.js.map +1 -1
  43. package/dist/modules/kb-fs/index.d.ts +7 -0
  44. package/dist/modules/kb-fs/index.d.ts.map +1 -0
  45. package/dist/modules/kb-fs/index.js +2 -0
  46. package/dist/modules/kb-fs/index.js.map +1 -0
  47. package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
  48. package/dist/modules/plugins/join-request-jobs.service.js +43 -8
  49. package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
  50. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  51. package/dist/modules/skills/skills.tools.d.ts +8 -1
  52. package/dist/modules/skills/skills.tools.d.ts.map +1 -1
  53. package/dist/modules/skills/skills.tools.js +115 -11
  54. package/dist/modules/skills/skills.tools.js.map +1 -1
  55. package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
  56. package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
  57. package/dist/modules/tool-helpers/default-branch-write.js +63 -0
  58. package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
  59. package/dist/modules/tool-helpers/index.d.ts +1 -0
  60. package/dist/modules/tool-helpers/index.d.ts.map +1 -1
  61. package/dist/modules/tool-helpers/index.js +1 -0
  62. package/dist/modules/tool-helpers/index.js.map +1 -1
  63. package/dist/modules/workflow/file-lock.service.d.ts +27 -5
  64. package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
  65. package/dist/modules/workflow/file-lock.service.js +70 -18
  66. package/dist/modules/workflow/file-lock.service.js.map +1 -1
  67. package/dist/modules/workflow/git/git.service.d.ts +13 -0
  68. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  69. package/dist/modules/workflow/git/git.service.js +24 -0
  70. package/dist/modules/workflow/git/git.service.js.map +1 -1
  71. package/dist/modules/workflow/index.d.ts +8 -0
  72. package/dist/modules/workflow/index.d.ts.map +1 -0
  73. package/dist/modules/workflow/index.js +8 -0
  74. package/dist/modules/workflow/index.js.map +1 -0
  75. package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
  76. package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
  77. package/dist/modules/workflow/pending-commits.service.js +4 -24
  78. package/dist/modules/workflow/pending-commits.service.js.map +1 -1
  79. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  80. package/dist/modules/workflow/workflow.routes.js +20 -1
  81. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  82. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  83. package/dist/modules/workflow/workflow.service.js +5 -3
  84. package/dist/modules/workflow/workflow.service.js.map +1 -1
  85. package/dist/modules/workspace/index.d.ts +10 -0
  86. package/dist/modules/workspace/index.d.ts.map +1 -0
  87. package/dist/modules/workspace/index.js +10 -0
  88. package/dist/modules/workspace/index.js.map +1 -0
  89. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  90. package/dist/modules/workspace/workspace.tools.js +106 -20
  91. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  92. package/dist/shared/workspace-id.d.ts +17 -0
  93. package/dist/shared/workspace-id.d.ts.map +1 -1
  94. package/dist/shared/workspace-id.js +24 -0
  95. package/dist/shared/workspace-id.js.map +1 -1
  96. package/kb-template/access.md +2 -1
  97. package/mcp-app/page.html +142 -8
  98. package/package.json +3 -3
  99. package/src/core/__tests__/compose-embed.test.ts +65 -0
  100. package/src/core/__tests__/lifecycle.test.ts +29 -26
  101. package/src/core/catalog-revision.ts +4 -2
  102. package/src/core/compose-embed.ts +50 -0
  103. package/src/core/core-ports.ts +10 -0
  104. package/src/core/create-core-server.ts +7 -1
  105. package/src/core/create-core-services.ts +27 -21
  106. package/src/core/lifecycle.ts +24 -12
  107. package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +23 -3
  108. package/src/modules/access/__tests__/access-control.atref-cache.test.ts +303 -25
  109. package/src/modules/access/__tests__/access-control.download-read.test.ts +3 -1
  110. package/src/modules/access/__tests__/access-control.service.test.ts +77 -16
  111. package/src/modules/access/__tests__/change-read-gate.test.ts +36 -5
  112. package/src/modules/access/access-control.interface.ts +5 -2
  113. package/src/modules/access/access-control.service.ts +236 -95
  114. package/src/modules/auth/index.ts +29 -0
  115. package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
  116. package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
  117. package/src/modules/embed/embed.interface.ts +24 -0
  118. package/src/modules/embed/embed.service.ts +44 -19
  119. package/src/modules/embed/embed.tools.ts +10 -2
  120. package/src/modules/kb-fs/index.ts +6 -0
  121. package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
  122. package/src/modules/plugins/join-request-jobs.service.ts +45 -8
  123. package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
  124. package/src/modules/skills/skills.contract.ts +4 -0
  125. package/src/modules/skills/skills.tools.ts +120 -10
  126. package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
  127. package/src/modules/tool-helpers/default-branch-write.ts +90 -0
  128. package/src/modules/tool-helpers/index.ts +5 -0
  129. package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +36 -2
  130. package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
  131. package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
  132. package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
  133. package/src/modules/workflow/file-lock.service.ts +79 -18
  134. package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
  135. package/src/modules/workflow/git/git.service.ts +23 -0
  136. package/src/modules/workflow/index.ts +7 -0
  137. package/src/modules/workflow/pending-commits.service.ts +4 -23
  138. package/src/modules/workflow/workflow.routes.ts +24 -1
  139. package/src/modules/workflow/workflow.service.ts +3 -2
  140. package/src/modules/workspace/__tests__/workspace.tools.test.ts +21 -11
  141. package/src/modules/workspace/index.ts +14 -0
  142. package/src/modules/workspace/workspace.tools.ts +120 -20
  143. package/src/shared/workspace-id.ts +24 -0
@@ -14,7 +14,8 @@ owner:
14
14
  #
15
15
  # Verbs are read, write, download and owner. `owner` implies the rest and
16
16
  # `write` implies `read`. Read is default-deny: a folder with no `read:` grant
17
- # is invisible to everyone except admins, who read through `write: Admin`.
17
+ # is invisible to everyone except admins, who may always change what is under
18
+ # the root and so may always see it, unless a nearer access.md excludes them.
18
19
  #
19
20
  # Each entry is a grant (a bare principal) or a denial (the lowercase word
20
21
  # `deny`, a space, then the principal — capitalised forms like `Deny` are
package/mcp-app/page.html CHANGED
@@ -72,6 +72,26 @@
72
72
  * owns the view; later ones are ignored.
73
73
  */
74
74
  var loading = false;
75
+ /**
76
+ * What the view calls `open_page` with to renew its token: the path
77
+ * and heading the result that owns the view echoed. Null until then,
78
+ * and when the result echoed no path (nothing to ask for).
79
+ */
80
+ var pageArgs = null;
81
+ /** The deployment the owning result named; a renewed token must name the same one. */
82
+ var deployment = null;
83
+ /**
84
+ * Whether the host runs a server tool for the view (`tools/call`,
85
+ * app to host): true or false when the handshake's reply said so in
86
+ * `hostCapabilities.serverTools`, null when it said nothing — then
87
+ * the view tries once, and a refusal is the answer.
88
+ */
89
+ var serverTools = null;
90
+ /** Replies the view is waiting for, by request id. */
91
+ var pending = {};
92
+ var callCount = 0;
93
+ /** The one renewal in flight, shared by every call refused meanwhile. */
94
+ var renewing = null;
75
95
 
76
96
  /** The name the deployment's Vite build gives the embed entry. */
77
97
  var EMBED_ENTRY = 'embed';
@@ -90,6 +110,14 @@
90
110
  * part of the conversation rather than the whole screen.
91
111
  */
92
112
  var PREFERRED_HEIGHT = 640;
113
+ /** The tool that minted the token, and the one that renews it. */
114
+ var OPEN_PAGE_TOOL = 'open_page';
115
+ /**
116
+ * How long a renewal waits for the host before it gives up and the
117
+ * view says it has expired. A host that never answers must not leave
118
+ * a save spinning.
119
+ */
120
+ var RENEW_TIMEOUT_MS = 20000;
93
121
 
94
122
  function send(message) {
95
123
  // The host is this view's parent; it told us its origin on the
@@ -128,6 +156,87 @@
128
156
  });
129
157
  }
130
158
 
159
+ /**
160
+ * Ask the host to call one of this server's tools for the view, over
161
+ * the connection the chat already holds — and so as the identity
162
+ * that connection authenticated. Resolves with the CallToolResult;
163
+ * rejects when the host refuses or does not answer in time.
164
+ */
165
+ function callServerTool(name, args) {
166
+ return new Promise(function (resolve, reject) {
167
+ callCount += 1;
168
+ var id = 'call-' + callCount;
169
+ var timer = setTimeout(function () {
170
+ delete pending[id];
171
+ var silent = new Error('the chat app did not answer');
172
+ silent.timedOut = true;
173
+ reject(silent);
174
+ }, RENEW_TIMEOUT_MS);
175
+ pending[id] = function (message) {
176
+ clearTimeout(timer);
177
+ delete pending[id];
178
+ if (message.error || !message.result) reject(message.error || new Error('no result'));
179
+ else resolve(message.result);
180
+ };
181
+ send({ jsonrpc: '2.0', id: id, method: 'tools/call', params: { name: name, arguments: args } });
182
+ });
183
+ }
184
+
185
+ /**
186
+ * A fresh token for the page this view shows, or null when there is
187
+ * none to be had. The view cannot mint one — minting needs the
188
+ * identity, which lives on the chat's connection — so it asks the
189
+ * host to call `open_page` again for the same path and heading and
190
+ * takes the token out of the `embedUrl` it answers. That call goes
191
+ * through `open_page`'s own gate: a reader whose access was
192
+ * withdrawn is refused there exactly as on a first call, and gets
193
+ * null here.
194
+ *
195
+ * Null without asking when the host said it runs no server tools for
196
+ * a view, or after a host that said nothing refused once. The bundle
197
+ * calls this at most once per refused call (see `embed-config.ts`).
198
+ */
199
+ function renew() {
200
+ if (renewing) return renewing;
201
+ if (serverTools === false || !pageArgs || !deployment) return Promise.resolve(null);
202
+ // Every Hexis tool takes its arguments under `body`; flat ones are
203
+ // refused ("takes its arguments under body") and no token comes back.
204
+ renewing = callServerTool(OPEN_PAGE_TOOL, { body: pageArgs })
205
+ .then(
206
+ function (result) {
207
+ var data = (result && result.structuredContent) || {};
208
+ if (result && result.isError) return null;
209
+ var url;
210
+ try {
211
+ url = new URL(data.embedUrl);
212
+ } catch (err) {
213
+ return null;
214
+ }
215
+ // The same deployment, or nothing: the bundle already runs
216
+ // against this one, and its API is the token's.
217
+ if (baseOf(url) !== deployment) return null;
218
+ var token = url.searchParams.get('token');
219
+ if (!token) return null;
220
+ var handoff = window[HANDOFF_GLOBAL];
221
+ if (handoff && typeof handoff === 'object') handoff.token = token;
222
+ return token;
223
+ },
224
+ function (err) {
225
+ // The host refused the call: one that said nothing about
226
+ // server tools has now said no. One that did not answer in
227
+ // time has said nothing, and is asked again on the next
228
+ // refused call.
229
+ if (serverTools === null && !(err && err.timedOut)) serverTools = false;
230
+ return null;
231
+ },
232
+ )
233
+ .then(function (token) {
234
+ renewing = null;
235
+ return token;
236
+ });
237
+ return renewing;
238
+ }
239
+
131
240
  /** The text blocks of a CallToolResult, one line each. */
132
241
  function textOf(result) {
133
242
  var out = [];
@@ -226,8 +335,15 @@
226
335
  }
227
336
  if (loading) return;
228
337
  loading = true;
338
+ deployment = base;
339
+ // What the view calls `open_page` with again when the token runs
340
+ // out: the arguments this result was answered for, as it echoed them.
341
+ if (typeof data.path === 'string' && data.path) {
342
+ pageArgs = { path: data.path };
343
+ if (typeof data.heading === 'string' && data.heading) pageArgs.heading = data.heading;
344
+ }
229
345
  // The handoff, before the bundle: it reads this the moment it runs.
230
- window[HANDOFF_GLOBAL] = { baseUrl: base, token: token, openLink: openLink };
346
+ window[HANDOFF_GLOBAL] = { baseUrl: base, token: token, openLink: openLink, renew: renew };
231
347
 
232
348
  fetch(base + MANIFEST_PATH, { credentials: 'omit' })
233
349
  .then(function (res) {
@@ -302,6 +418,13 @@
302
418
  if (event.source !== window.parent) return;
303
419
  if (hostOrigin === null && event.origin) hostOrigin = event.origin;
304
420
 
421
+ // A reply to a request the view sent (a server tool call): it has
422
+ // the request's id and no method of its own.
423
+ if (!message.method && typeof message.id === 'string' && pending[message.id]) {
424
+ pending[message.id](message);
425
+ return;
426
+ }
427
+
305
428
  if (message.method === 'ui/notifications/tool-result') {
306
429
  // The params ARE the CallToolResult. A `result` wrapper is
307
430
  // accepted too, for a host that wrapped it.
@@ -314,16 +437,25 @@
314
437
  // whole of the layout; nothing to recompute.
315
438
  return;
316
439
  }
317
- // The handshake's reply carries the host's capabilities. Nothing
318
- // here depends on them, but a host that sends the first tool result
319
- // only after the handshake completes needs us to have sent it.
320
- if (message.id === INITIALIZE_ID) {
440
+ // The handshake's reply carries the host's capabilities. The one
441
+ // read here is `serverTools`: whether the host runs this server's
442
+ // tools for the view, which is how the view renews its token. And a
443
+ // host that sends the first tool result only after the handshake
444
+ // completes needs us to have sent it.
445
+ if (!message.method && message.id === INITIALIZE_ID) {
321
446
  // A rejected handshake is not advertised as ready — and the
322
447
  // reader is told, rather than left at "Opening the page…".
323
448
  if (message.error || !message.result) {
324
449
  if (!shown) say('The chat app refused to open this page.');
325
450
  return;
326
451
  }
452
+ // Only a `serverTools` the host named decides: present is yes,
453
+ // false or null is no. Capabilities that leave it out have said
454
+ // nothing about it, and the view tries once.
455
+ var caps = message.result.hostCapabilities;
456
+ if (caps && typeof caps === 'object' && caps.serverTools !== undefined) {
457
+ serverTools = !!caps.serverTools;
458
+ }
327
459
  send({ jsonrpc: '2.0', method: 'ui/notifications/initialized', params: {} });
328
460
  if (!shown) say('Opening the page…');
329
461
  }
@@ -337,9 +469,11 @@
337
469
  params: {
338
470
  protocolVersion: '2026-01-26',
339
471
  appInfo: { name: 'hexis-knowledge-page', version: '1.0.0' },
340
- // Asks the host for nothing: no tool calls, no messages, no
341
- // model context. Links go out through ui/open-link, which every
342
- // host offers.
472
+ // Declares nothing. The extension has no way for a view to say
473
+ // it CALLS server tools (`tools` here would mean the view
474
+ // exposes tools of its own); whether the host runs them for a
475
+ // view is the host's to say, in the reply's `serverTools`.
476
+ // Links go out through ui/open-link, which every host offers.
343
477
  appCapabilities: {},
344
478
  },
345
479
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bevel-software/platform-core-backend",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Open-source core backend of the Bevel platform: git-backed knowledge workspace, workflow (branches/change requests/locks/SSE), skills, tools, secrets vault, access control and the remote MCP surface.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,8 +55,8 @@
55
55
  "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
56
56
  "yaml": "^2.9.0",
57
57
  "zod": "^3.24.0",
58
- "@bevel-software/platform-mcp-core": "0.28.0",
59
- "@bevel-software/platform-shared": "0.28.0"
58
+ "@bevel-software/platform-mcp-core": "0.29.0",
59
+ "@bevel-software/platform-shared": "0.29.0"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/adm-zip": "^0.5.8",
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { testKbContext, TEST_BRANCH_MODEL } from '../../__tests__/kb-context.js';
3
+ import { makeRolesYamlWriteValidator } from '../../modules/access-model/roles-yaml-guard.js';
4
+ import { createFileReaderRegistry } from '../../modules/workspace/file-readers/file-reader.registry.js';
5
+ import type { DocExtractOutcome, DocExtractService } from '../../modules/workspace/file-readers/doc-extract.service.js';
6
+ import { composeEmbedService, type EmbedComposition } from '../compose-embed.js';
7
+ import { EmbedRefParseError } from '../../modules/embed/embed-link.js';
8
+
9
+ const KB = 'knowledge-base';
10
+ const BRANCH = TEST_BRANCH_MODEL.defaultBranch;
11
+ const REPO = 'Data/Thing.md';
12
+ const USER = { id: 'u-1', email: 'alice@bevel.software', name: 'Alice' };
13
+
14
+ /** The composition root's deps, as mocks: enough to mint and to say who is looking. */
15
+ function deps(): EmbedComposition {
16
+ const extract = async (): Promise<DocExtractOutcome> => ({ ok: false, message: 'no documents here' });
17
+ return {
18
+ config: { jwtSecret: 'test-jwt-secret', embedSharedSecret: '', publicFrontendUrl: 'https://hexis.example', kbDirName: KB },
19
+ kb: testKbContext({ kbDirName: KB }),
20
+ workspaceService: {
21
+ getOrCreateForBranch: vi.fn(async (branch: string) => ({ id: encodeURIComponent(branch), kbDirName: KB })),
22
+ isFile: vi.fn(async (_id: string, wsPath: string) => wsPath === `${KB}/${REPO}`),
23
+ readFileBinary: vi.fn(async () => Buffer.from('# Thing\n')),
24
+ writeFile: vi.fn(async () => undefined),
25
+ withPathTurn: vi.fn(async (_id: string, _p: string, op: () => Promise<unknown>) => op()),
26
+ },
27
+ accessControl: { canRead: vi.fn(async () => true), canWrite: vi.fn(async () => true) } as never,
28
+ authService: { getUserById: vi.fn(async () => USER), isEmailDomainAllowed: () => true, isActive: async () => true },
29
+ workflowService: {} as never,
30
+ gitService: {} as never,
31
+ accountLinks: { getUserId: async () => null, link: async () => undefined, listForUser: async () => [], unlink: async () => true },
32
+ readers: createFileReaderRegistry({ extract } satisfies Pick<DocExtractService, 'extract'> as DocExtractService),
33
+ validateWrite: makeRolesYamlWriteValidator(KB),
34
+ };
35
+ }
36
+
37
+ /**
38
+ * The composition root is the one place the distribution's ports reach the
39
+ * embed. Pinned here through that composition, not by handing the service a
40
+ * resolver directly: a node-id reference resolves through the port when one
41
+ * is registered, and is refused when none is — which is what a core-only
42
+ * deployment ships.
43
+ */
44
+ describe('composeEmbedService', () => {
45
+ // The app's copy-link form: the full address of a node, which is its id.
46
+ const NODE_LINK = `https://hexis.example/workspace/${encodeURIComponent(BRANCH)}/abc-123`;
47
+
48
+ it('resolves a node-id reference through the registered port, and mints for the file it names', async () => {
49
+ const embedNodeIdResolver = vi.fn(async (id: string) => (id === 'abc-123' ? REPO : null));
50
+ const service = composeEmbedService(deps(), { embedNodeIdResolver });
51
+ const { token } = await service.mintForUser({ userId: USER.id, reference: NODE_LINK });
52
+ expect(embedNodeIdResolver).toHaveBeenCalledWith('abc-123');
53
+ expect((await service.viewerOf(token)).repoRelative).toBe(REPO);
54
+ // An id the graph does not know is refused as a dead reference.
55
+ await expect(service.mintForUser({ userId: USER.id, reference: 'nope-1' })).rejects.toThrow(EmbedRefParseError);
56
+ });
57
+
58
+ it('refuses a node-id reference when no port is registered — core has no graph', async () => {
59
+ const service = composeEmbedService(deps(), {});
60
+ await expect(service.mintForUser({ userId: USER.id, reference: NODE_LINK })).rejects.toThrow(EmbedRefParseError);
61
+ // A path reference is untouched by the absence of a resolver.
62
+ const { token } = await service.mintForUser({ userId: USER.id, reference: REPO });
63
+ expect((await service.viewerOf(token)).repoRelative).toBe(REPO);
64
+ });
65
+ });
@@ -1,5 +1,8 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { createShutdown, holdCommitWorkerLease, holdable, stopCore, withStartupTask, type LeasedWorker } from '../lifecycle.js';
2
+ import { createShutdown, stopCore, withStartupTask, type LeasedWorker } from '../../index.js';
3
+ // The lease helpers are the module's own, not on the entry point: their
4
+ // ordering is pinned here, where the module is the unit under test.
5
+ import { holdCommitWorkerLease, holdable } from '../lifecycle.js';
3
6
  import { BevelSecretsVariableLoader, registerBevelSecretsVariableLoader } from '../../modules/secrets-vault/secrets-variable-loader.js';
4
7
  import type { AdvisoryLease } from '../../modules/database/advisory-lock.js';
5
8
 
@@ -254,12 +257,12 @@ describe('createShutdown', () => {
254
257
  order.push('commitWorker.stop');
255
258
  },
256
259
  },
257
- backgroundJobs: {
260
+ pluginJoinRequestJobs: {
258
261
  stopSweeping() {
259
- order.push('backgroundJobs.stopSweeping');
262
+ order.push('pluginJoinRequestJobs.stopSweeping');
260
263
  },
261
264
  async drain() {
262
- order.push('backgroundJobs.drain');
265
+ order.push('pluginJoinRequestJobs.drain');
263
266
  },
264
267
  },
265
268
  agentUploadStore: {
@@ -296,12 +299,12 @@ describe('createShutdown', () => {
296
299
  expect(d.order).toEqual([
297
300
  'server.close',
298
301
  'server.closeAllConnections',
299
- 'backgroundJobs.stopSweeping',
302
+ 'pluginJoinRequestJobs.stopSweeping',
300
303
  // The upload sweep is stopped in the same breath as the other timers,
301
304
  // and drained before the pool goes: a graph that is stopping must not
302
305
  // leave a sweep walking the staging root its replacement will stage into.
303
306
  'agentUploadStore.stopSweeping',
304
- 'backgroundJobs.drain',
307
+ 'pluginJoinRequestJobs.drain',
305
308
  'agentUploadStore.drainSweep',
306
309
  'commitWorker.stop',
307
310
  'db.end',
@@ -311,8 +314,8 @@ describe('createShutdown', () => {
311
314
  it('waits for the jobs a tick already started before the pool goes, within the budget', async () => {
312
315
  const d = deps();
313
316
  let release: () => void = () => undefined;
314
- d.deps.backgroundJobs!.drain = () => {
315
- d.order.push('backgroundJobs.drain');
317
+ d.deps.pluginJoinRequestJobs!.drain = () => {
318
+ d.order.push('pluginJoinRequestJobs.drain');
316
319
  return new Promise<void>((resolve) => {
317
320
  release = resolve;
318
321
  });
@@ -326,9 +329,9 @@ describe('createShutdown', () => {
326
329
  expect(d.order).toEqual([
327
330
  'server.close',
328
331
  'server.closeAllConnections',
329
- 'backgroundJobs.stopSweeping',
332
+ 'pluginJoinRequestJobs.stopSweeping',
330
333
  'agentUploadStore.stopSweeping',
331
- 'backgroundJobs.drain',
334
+ 'pluginJoinRequestJobs.drain',
332
335
  ]);
333
336
  release();
334
337
  await done;
@@ -348,7 +351,7 @@ describe('createShutdown', () => {
348
351
  // `commitWorker.stop` — which awaits an in-flight commit and can take
349
352
  // most of the shutdown budget — would start fresh clone/commit/push work
350
353
  // that the process exit immediately after kills mid-git.
351
- const stopped = d.order.indexOf('backgroundJobs.stopSweeping');
354
+ const stopped = d.order.indexOf('pluginJoinRequestJobs.stopSweeping');
352
355
  expect(stopped).toBeGreaterThanOrEqual(0);
353
356
  expect(stopped).toBeLessThan(d.order.indexOf('commitWorker.stop'));
354
357
  expect(stopped).toBeLessThan(d.order.indexOf('db.end'));
@@ -357,7 +360,7 @@ describe('createShutdown', () => {
357
360
  it('shuts down cleanly when no background jobs were built yet', async () => {
358
361
  const d = deps();
359
362
  // A stop that lands mid-boot: the services exist, the jobs do not.
360
- delete (d.deps as { backgroundJobs?: unknown }).backgroundJobs;
363
+ delete (d.deps as { pluginJoinRequestJobs?: unknown }).pluginJoinRequestJobs;
361
364
  delete (d.deps as { agentUploadStore?: unknown }).agentUploadStore;
362
365
  const shutdown = createShutdown(d.deps as never);
363
366
  const done = shutdown('SIGTERM');
@@ -374,8 +377,8 @@ describe('createShutdown', () => {
374
377
 
375
378
  it('does not wait past the budget for jobs that never finish, and still ends the pool', async () => {
376
379
  const d = deps();
377
- d.deps.backgroundJobs!.drain = () => {
378
- d.order.push('backgroundJobs.drain');
380
+ d.deps.pluginJoinRequestJobs!.drain = () => {
381
+ d.order.push('pluginJoinRequestJobs.drain');
379
382
  return new Promise<void>(() => undefined); // a clone that never returns
380
383
  };
381
384
  const shutdown = createShutdown(d.deps as never, { deadlineMs: 60 });
@@ -384,7 +387,7 @@ describe('createShutdown', () => {
384
387
  d.finishClose();
385
388
  await done;
386
389
  expect(d.order.slice(-4)).toEqual([
387
- 'backgroundJobs.drain',
390
+ 'pluginJoinRequestJobs.drain',
388
391
  'agentUploadStore.drainSweep',
389
392
  'commitWorker.stop',
390
393
  'db.end',
@@ -400,9 +403,9 @@ describe('createShutdown', () => {
400
403
  expect(d.order).toEqual([
401
404
  'server.close',
402
405
  'server.closeAllConnections',
403
- 'backgroundJobs.stopSweeping',
406
+ 'pluginJoinRequestJobs.stopSweeping',
404
407
  'agentUploadStore.stopSweeping',
405
- 'backgroundJobs.drain',
408
+ 'pluginJoinRequestJobs.drain',
406
409
  'agentUploadStore.drainSweep',
407
410
  'commitWorker.stop',
408
411
  'db.end',
@@ -432,12 +435,12 @@ describe('stopCore', () => {
432
435
  order.push('commitWorker.stop');
433
436
  },
434
437
  },
435
- backgroundJobs: {
438
+ pluginJoinRequestJobs: {
436
439
  stopSweeping() {
437
- order.push('backgroundJobs.stopSweeping');
440
+ order.push('pluginJoinRequestJobs.stopSweeping');
438
441
  },
439
442
  async drain() {
440
- order.push('backgroundJobs.drain');
443
+ order.push('pluginJoinRequestJobs.drain');
441
444
  },
442
445
  },
443
446
  agentUploadStore: {
@@ -478,11 +481,11 @@ describe('stopCore', () => {
478
481
  const g = graph();
479
482
  await stopCore(g.core as never);
480
483
  expect(g.order).toEqual([
481
- 'backgroundJobs.stopSweeping',
484
+ 'pluginJoinRequestJobs.stopSweeping',
482
485
  'agentUploadStore.stopSweeping',
483
486
  'startupRetry.stop',
484
487
  'workflowService.stopTidying',
485
- 'backgroundJobs.drain',
488
+ 'pluginJoinRequestJobs.drain',
486
489
  'agentUploadStore.drainSweep',
487
490
  'workflowService.drainTidy',
488
491
  'commitWorker.stop',
@@ -523,10 +526,10 @@ describe('stopCore', () => {
523
526
  const core = { ...g.core, startupRetry: null, secretsScope: undefined };
524
527
  await stopCore(core as never);
525
528
  expect(g.order).toEqual([
526
- 'backgroundJobs.stopSweeping',
529
+ 'pluginJoinRequestJobs.stopSweeping',
527
530
  'agentUploadStore.stopSweeping',
528
531
  'workflowService.stopTidying',
529
- 'backgroundJobs.drain',
532
+ 'pluginJoinRequestJobs.drain',
530
533
  'agentUploadStore.drainSweep',
531
534
  'workflowService.drainTidy',
532
535
  'commitWorker.stop',
@@ -539,11 +542,11 @@ describe('stopCore', () => {
539
542
  const core = { ...g.core, commitWorker: { stop: () => new Promise<void>(() => undefined) } };
540
543
  await stopCore(core as never, { deadlineMs: 50 });
541
544
  expect(g.order).toEqual([
542
- 'backgroundJobs.stopSweeping',
545
+ 'pluginJoinRequestJobs.stopSweeping',
543
546
  'agentUploadStore.stopSweeping',
544
547
  'startupRetry.stop',
545
548
  'workflowService.stopTidying',
546
- 'backgroundJobs.drain',
549
+ 'pluginJoinRequestJobs.drain',
547
550
  'agentUploadStore.drainSweep',
548
551
  'workflowService.drainTidy',
549
552
  'db.end',
@@ -29,10 +29,12 @@ const log = logger('catalog-revision');
29
29
  */
30
30
 
31
31
  /**
32
- * A skill's line: everything `list_skills` shows about it on an AGENT
32
+ * A skill's line: what `list_skills` shows about the skill ITSELF on an AGENT
33
33
  * surface. Not `plugins`, which only the browser route decorates onto the
34
34
  * catalog — a field no agent-facing listing carries cannot be a change one
35
- * owes a refresh for.
35
+ * owes a refresh for. Not `canWrite` either: it is a verdict about the caller,
36
+ * judged per call by the tool, not part of the skill, and it is never on the
37
+ * summaries this digest reads.
36
38
  */
37
39
  function skillFingerprint(s: SkillSummary): string {
38
40
  return [s.name, s.path, s.version ?? '', s.description].join('\u0000');
@@ -0,0 +1,50 @@
1
+ import type { IGitService, IWorkflowService } from '@bevel-software/platform-shared';
2
+ import type { KbContext } from '../shared/kb-context.js';
3
+ import type { IAccessControl } from '../modules/access/access-control.interface.js';
4
+ import type { WriteValidator } from '../modules/kb-fs/index.js';
5
+ import type { FileReaderRegistry } from '../modules/workspace/index.js';
6
+ import type { CorePorts } from './core-ports.js';
7
+ import type { IAccountLinkService } from '../modules/embed/account-link.service.js';
8
+ import type { EmbedAuthPort, EmbedWorkspacePort } from '../modules/embed/embed.interface.js';
9
+ import { EmbedService, type EmbedConfig } from '../modules/embed/embed.service.js';
10
+
11
+ /** What the composition root hands the embed: the services it reads and writes through. */
12
+ export interface EmbedComposition {
13
+ config: EmbedConfig;
14
+ kb: KbContext;
15
+ workspaceService: EmbedWorkspacePort;
16
+ accessControl: IAccessControl;
17
+ authService: EmbedAuthPort;
18
+ workflowService: IWorkflowService;
19
+ gitService: IGitService;
20
+ accountLinks: IAccountLinkService;
21
+ /** The SAME extension-to-reader registry `read_file` dispatches on. */
22
+ readers: FileReaderRegistry;
23
+ /** The SAME pre-disk gate the file editor and the agent tools run. */
24
+ validateWrite: WriteValidator;
25
+ }
26
+
27
+ /**
28
+ * The embed service as the composition root builds it — the one place the
29
+ * distribution's ports reach it. Lives in `core/`, beside the composition
30
+ * root it serves: the ports are core's contract with a distribution, and a
31
+ * module never reaches up into core. A node-id reference (`/workspace/<branch>/
32
+ * <id>`, the app's copy-link form) resolves only where a node graph is: the
33
+ * distribution's, when it registers `ports.embedNodeIdResolver`; core has
34
+ * none and refuses such a reference.
35
+ */
36
+ export function composeEmbedService(deps: EmbedComposition, ports: Pick<CorePorts, 'embedNodeIdResolver'>): EmbedService {
37
+ return new EmbedService(
38
+ deps.config,
39
+ deps.kb,
40
+ deps.workspaceService,
41
+ deps.accessControl,
42
+ deps.authService,
43
+ deps.workflowService,
44
+ deps.gitService,
45
+ deps.accountLinks,
46
+ deps.readers,
47
+ deps.validateWrite,
48
+ ports.embedNodeIdResolver ?? null,
49
+ );
50
+ }
@@ -10,6 +10,7 @@ import type { IAccountAdmission } from '../modules/auth/account-admission.js';
10
10
  import type { IWriteAccess } from '../modules/write-access/write-access.js';
11
11
  import type { OnServerStart } from '../modules/workspace/startup/on-server-start.js';
12
12
  import type { AgentGuideHook } from '../modules/agent-guide/agent-guide.js';
13
+ import type { EmbedNodeIdResolver } from '../modules/embed/embed.interface.js';
13
14
 
14
15
  /**
15
16
  * Every seam the enterprise overlay can fill in the CORE composition
@@ -138,6 +139,15 @@ export interface CorePorts {
138
139
  * `modules/write-access/write-access.ts`.
139
140
  */
140
141
  writeAccess?: IWriteAccess;
142
+ /**
143
+ * How an embed reference that is a bare NODE ID (`/workspace/<branch>/<id>`,
144
+ * the app's copy-link form) becomes the repository path it names. Core has
145
+ * no node graph, so core refuses such a reference; a distribution with one
146
+ * answers from it, and the Atlassian connector's links to nodes mint as
147
+ * they did when that distribution served the embed itself. Returns null
148
+ * for an id that names nothing. See `modules/embed/embed.interface.ts`.
149
+ */
150
+ embedNodeIdResolver?: EmbedNodeIdResolver;
141
151
  }
142
152
 
143
153
  /**
@@ -509,7 +509,13 @@ export async function createCoreServer(
509
509
  // one-time link is the whole credential an agent's `curl` carries. Every
510
510
  // file behind it was judged when `request_file_download` issued it.
511
511
  toolsRouter.use(createAgentDownloadRoutes({ downloads: core.agentDownloadStore, identify: core.agentDownloadFetcher }));
512
- registerSkillsTools(core.toolRegistry, toolsRouter, ta, th, core.skillService, allowedToolsChecker);
512
+ registerSkillsTools(core.toolRegistry, toolsRouter, ta, th, core.skillService, allowedToolsChecker, {
513
+ accessControl: core.accessControl,
514
+ defaultWorkspaceId: () => core.kb.defaultWorkspaceId(),
515
+ // A skill's files as committed on the default branch, for the whole-skill
516
+ // verdict: the same clone and the same git the skill's history is read from.
517
+ committedFiles: core.gitService,
518
+ });
513
519
  // Definitions only: the endpoints they describe are the app's own plugin
514
520
  // creation routes, mounted below behind the key-or-session gate.
515
521
  registerPluginsTools(core.toolRegistry, core.kb);
@@ -18,7 +18,8 @@ import { runCoreMigrations } from '../modules/database/migrate.js';
18
18
  import { coreMigrationsDir } from '../assets.js';
19
19
  import { WorkspaceService } from '../modules/workspace/workspace.service.js';
20
20
  import { AccountLinkService } from '../modules/embed/account-link.service.js';
21
- import { EmbedService } from '../modules/embed/embed.service.js';
21
+ import type { EmbedService } from '../modules/embed/embed.service.js';
22
+ import { composeEmbedService } from './compose-embed.js';
22
23
  import { McpAppService, type IMcpAppService } from '../modules/embed/mcp-app.js';
23
24
  import { createFileReaderRegistry } from '../modules/workspace/file-readers/file-reader.registry.js';
24
25
  import { RoutineWritePolicyService } from '../modules/workspace/routine-write-policy.js';
@@ -1288,26 +1289,31 @@ export async function createCoreServices(
1288
1289
  // judges them with the same access resolver the app file page uses, so what
1289
1290
  // a reader may see and change inside a chat is what they may see and change
1290
1291
  // in the app — never a second answer to the same question.
1291
- const embedService = new EmbedService(
1292
- // The RESOLVED checkout folder, not the env's: `config.kbDirName` is only
1293
- // the environment value, empty on a deployment that took the default or
1294
- // named it in setup, and every path the embed builds starts with it.
1295
- { ...config, kbDirName },
1296
- kb,
1297
- workspaceService,
1298
- accessControl,
1299
- authService,
1300
- workflowService,
1301
- gitService,
1302
- new AccountLinkService(db),
1303
- // The SAME extension-to-reader registry `read_file` dispatches on, so the
1304
- // embed answer to "is this text, or bytes a renderer fetches?" cannot
1305
- // disagree with what a read of the file returns.
1306
- createFileReaderRegistry(docExtractService),
1307
- // The SAME pre-disk gate the file editor and the agent tools run: a
1308
- // `roles.yaml` that would not parse is refused before it is written, from
1309
- // a chat exactly as from the app.
1310
- makeRolesYamlWriteValidator(kbDirName),
1292
+ const embedService = composeEmbedService(
1293
+ {
1294
+ // The RESOLVED checkout folder, not the env's: `config.kbDirName` is only
1295
+ // the environment value, empty on a deployment that took the default or
1296
+ // named it in setup, and every path the embed builds starts with it.
1297
+ config: { ...config, kbDirName },
1298
+ kb,
1299
+ workspaceService,
1300
+ accessControl,
1301
+ authService,
1302
+ workflowService,
1303
+ gitService,
1304
+ accountLinks: new AccountLinkService(db),
1305
+ // The SAME extension-to-reader registry `read_file` dispatches on, so the
1306
+ // embed answer to "is this text, or bytes a renderer fetches?" cannot
1307
+ // disagree with what a read of the file returns.
1308
+ readers: createFileReaderRegistry(docExtractService),
1309
+ // The SAME pre-disk gate the file editor and the agent tools run: a
1310
+ // `roles.yaml` that would not parse is refused before it is written, from
1311
+ // a chat exactly as from the app.
1312
+ validateWrite: makeRolesYamlWriteValidator(kbDirName),
1313
+ },
1314
+ // A node-id reference resolves only where a node graph is: the
1315
+ // distribution's, when it registers one (`ports.embedNodeIdResolver`).
1316
+ ports,
1311
1317
  );
1312
1318
  // The `ui://` view `open_page` carries, with the one origin it may frame:
1313
1319
  // this deployment own public origin.