@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
@@ -168,15 +168,51 @@ describe('EmbedService: minting', () => {
168
168
  /**
169
169
  * The token rides in the tool result and so in the chat transcript;
170
170
  * whoever holds it acts on that one file as its user until it expires.
171
- * One hour (Razvan, 2026-10-09), down from the two the embed used before.
171
+ * Five minutes (Razvan, 2026-10-09): the chat view renews it through its
172
+ * host when a call is refused, so a short life costs the reader nothing.
172
173
  */
173
- it('mints a token that lives one hour', async () => {
174
+ it('mints an open_page token that lives five minutes', async () => {
174
175
  const { service } = build();
175
176
  const { token } = await service.mintForUser({ userId: USER.id, reference: REPO });
176
177
  const { iat, exp } = claimsOf(token) as { iat: number; exp: number };
178
+ expect(exp - iat).toBe(5 * 60);
179
+ });
180
+
181
+ /** The connector frames `/embed` and has no host to renew through: its hour stands. */
182
+ it('mints a connector token that keeps its one-hour lifetime', async () => {
183
+ const { service } = build();
184
+ const { token } = await service.mintToken({ accountId: 'acc-1', email: USER.email, reference: REPO });
185
+ const { iat, exp } = claimsOf(token) as { iat: number; exp: number };
177
186
  expect(exp - iat).toBe(60 * 60);
178
187
  });
179
188
 
189
+ /**
190
+ * Both kinds are read by the same verification: each loads while it lives
191
+ * and is refused as a token once it has run out — the user's after five
192
+ * minutes, the connector's only after the hour.
193
+ */
194
+ it('reads both kinds while they live and refuses each once its own lifetime is over', async () => {
195
+ vi.useFakeTimers({ toFake: ['Date'] });
196
+ try {
197
+ vi.setSystemTime(new Date('2026-10-09T10:00:00Z'));
198
+ const { service } = build();
199
+ const user = await service.mintForUser({ userId: USER.id, reference: REPO });
200
+ const connector = await service.mintToken({ accountId: 'acc-1', email: USER.email, reference: REPO });
201
+
202
+ vi.setSystemTime(new Date('2026-10-09T10:04:50Z'));
203
+ await expect(service.loadFile(user.token)).resolves.toMatchObject({ repoRelative: REPO });
204
+
205
+ vi.setSystemTime(new Date('2026-10-09T10:05:01Z'));
206
+ await expect(service.loadFile(user.token)).rejects.toThrow(EmbedTokenError);
207
+ await expect(service.loadFile(connector.token)).resolves.toMatchObject({ repoRelative: REPO });
208
+
209
+ vi.setSystemTime(new Date('2026-10-09T11:00:01Z'));
210
+ await expect(service.loadFile(connector.token)).rejects.toThrow(EmbedTokenError);
211
+ } finally {
212
+ vi.useRealTimers();
213
+ }
214
+ });
215
+
180
216
  it('mints for an outside account — the connector path, unchanged', async () => {
181
217
  const { service } = build();
182
218
  const { token } = await service.mintToken({
@@ -189,7 +225,9 @@ describe('EmbedService: minting', () => {
189
225
 
190
226
  it('keeps the token PSEUDONYMOUS — no address, no name', async () => {
191
227
  const { service } = build();
192
- const { token } = await service.mintToken({ accountId: 'acc-1', email: USER.email, reference: REPO });
228
+ const { token, embedUrl } = await service.mintToken({ accountId: 'acc-1', email: USER.email, reference: REPO });
229
+ // The connector's host sizes its frame to the content: its view is told so.
230
+ expect(embedUrl).toBe(`https://hexis.example/embed?token=${encodeURIComponent(token)}&sizing=content`);
193
231
  expect(JSON.stringify(claimsOf(token))).not.toContain(USER.email);
194
232
  expect(JSON.stringify(claimsOf(token))).not.toContain(USER.name);
195
233
  });
@@ -711,6 +749,41 @@ describe('EmbedService: a switched-off account', () => {
711
749
  });
712
750
  });
713
751
 
752
+ /**
753
+ * A distribution's own token-authed route beside the embed (a graph for a
754
+ * dashboard) asks who is looking before it serves anything, and must judge
755
+ * the viewer exactly as the embed does.
756
+ */
757
+ describe('EmbedService: who is looking through a token', () => {
758
+ it('answers the linked user, their verdicts on the file, and the file', async () => {
759
+ const { service } = build({ canWrite: false });
760
+ const { token } = await service.mintForUser({ userId: USER.id, reference: `${REPO}#intro` });
761
+ expect(await service.viewerOf(token)).toEqual({
762
+ linked: true,
763
+ user: expect.objectContaining({ id: USER.id, email: USER.email }),
764
+ canRead: true,
765
+ canWrite: false,
766
+ repoRelative: REPO,
767
+ });
768
+ });
769
+
770
+ it('answers nobody for an unlinked outside account, and refuses a switched-off one as the embed does', async () => {
771
+ const unlinked = build({ linkedUserId: null });
772
+ const outside = await unlinked.service.mintToken({ accountId: 'acc-1', reference: REPO });
773
+ expect(await unlinked.service.viewerOf(outside.token)).toEqual({
774
+ linked: false,
775
+ user: null,
776
+ canRead: false,
777
+ canWrite: false,
778
+ repoRelative: REPO,
779
+ });
780
+ const off = build({ active: false });
781
+ const { token } = await off.service.mintForUser({ userId: USER.id, reference: REPO });
782
+ await expect(off.service.viewerOf(token)).rejects.toThrow(ACCOUNT_DEACTIVATED_MESSAGE);
783
+ await expect(off.service.viewerOf('not-a-token')).rejects.toThrow(EmbedTokenError);
784
+ });
785
+ });
786
+
714
787
  describe('EmbedService: the roles.yaml write gate', () => {
715
788
  const ROLES = `${KB}/roles.yaml`;
716
789
  const VALID = 'roles:\n Admin:\n - a@x.eu\n';
@@ -2,6 +2,7 @@ import type { Server } from 'node:http';
2
2
  import type { AddressInfo } from 'node:net';
3
3
  import express from 'express';
4
4
  import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { checkFor } from '@bevel-software/platform-mcp-core';
5
6
  import { ToolRegistry } from '../../tool-registry/tool-registry.js';
6
7
  import { createToolHandlerFactory } from '../../tool-helpers/tool-handler.js';
7
8
  import { ToolError, type ToolContext } from '../../tool-helpers/tool.contract.js';
@@ -122,6 +123,21 @@ describe('open_page: the listing', () => {
122
123
  expect(internal).not.toContain(OPEN_PAGE_TOOL);
123
124
  });
124
125
 
126
+ /**
127
+ * The chat view renews its token by having its host call this tool with
128
+ * `{ body: { path, heading } }` (see `mcp-app/page.html`). The listing's
129
+ * own check decides what `/api/mcp` accepts: that shape passes it, and the
130
+ * flat one the view once sent is refused, so no token ever came back.
131
+ */
132
+ it("accepts the chat view's renewal arguments under body, and refuses them flat", async () => {
133
+ const { registry } = await serve();
134
+ const def = (await registry.listExternal()).find((t) => t.name === OPEN_PAGE_TOOL)!;
135
+ const compiled = checkFor(def.inputs);
136
+ if (!compiled.checkable) throw new Error(`open_page's schema is not checkable: ${compiled.reason}`);
137
+ expect(compiled.check({ body: { path: 'Data/Thing.md', heading: 'what-it-is' } })).toEqual([]);
138
+ expect(compiled.check({ path: 'Data/Thing.md', heading: 'what-it-is' })).not.toEqual([]);
139
+ });
140
+
125
141
  it('takes a path and an optional heading, and no branch at all', async () => {
126
142
  const { registry } = await serve();
127
143
  const def = (await registry.listExternal()).find((t) => t.name === OPEN_PAGE_TOOL)!;
@@ -197,6 +213,19 @@ describe('open_page: the answer', () => {
197
213
  expect(body.appUrl).toContain('#what-it-is');
198
214
  });
199
215
 
216
+ /**
217
+ * The view renews its token by calling this tool again with the arguments
218
+ * it was opened with, and the result is all it is told — so the heading
219
+ * comes back beside the path, and is absent when none was named.
220
+ */
221
+ it('echoes the heading beside the path, for the view to call again with', async () => {
222
+ const { call } = await serve();
223
+ const { body } = await call({ path: 'Data/Thing.md', heading: 'what-it-is' });
224
+ expect(body).toMatchObject({ path: 'Data/Thing.md', heading: 'what-it-is' });
225
+ const plain = await call({ path: 'Data/Thing.md' });
226
+ expect(plain.body).not.toHaveProperty('heading');
227
+ });
228
+
200
229
  it('accepts a path with or without the knowledge-base prefix, as read_file documents', async () => {
201
230
  const { call, mints } = await serve();
202
231
  await call({ path: 'Data/Thing.md' });
@@ -103,6 +103,19 @@ export interface EmbedLinkedAccount {
103
103
  */
104
104
  export type EmbedNodeIdResolver = (nodeId: string) => Promise<string | null>;
105
105
 
106
+ /** The viewer behind a token, as {@link IEmbedService.viewerOf} answers it. */
107
+ export interface EmbedViewer {
108
+ /** Whether the token's identity resolved to a user at all. */
109
+ linked: boolean;
110
+ /** The linked user; null when unlinked. */
111
+ user: AuthUser | null;
112
+ /** Read access on the token's file — always false when unlinked. */
113
+ canRead: boolean;
114
+ canWrite: boolean;
115
+ /** The token's file, repository-relative. */
116
+ repoRelative: string;
117
+ }
118
+
106
119
  /**
107
120
  * What the embed service needs of the workspace: the clone of a branch, and
108
121
  * four verbs on one file of it. A port rather than the workspace service
@@ -165,6 +178,17 @@ export interface IEmbedService {
165
178
  /** Load the file the token is scoped to, with the viewer's read/write status. */
166
179
  loadFile(token: string): Promise<EmbedFileView>;
167
180
 
181
+ /**
182
+ * Who is looking through `token`, and at what: the resolved viewer (the
183
+ * linked user, or none), their read and write verdicts on the token's
184
+ * file, and the file. The one answer a distribution's own token-authed
185
+ * route beside this surface (a graph for a dashboard, say) asks before it
186
+ * serves anything, so it judges the viewer exactly as the embed does — the
187
+ * same token check, the same account link, the same switched-off refusal.
188
+ * Throws as `loadFile` throws for a token that is invalid or expired.
189
+ */
190
+ viewerOf(token: string): Promise<EmbedViewer>;
191
+
168
192
  /**
169
193
  * The raw bytes of the embedded file, or of one asset `path` beside it (an
170
194
  * image a markdown page shows — a file no reader edits as text, or an SVG,
@@ -1,6 +1,5 @@
1
1
  import { createHmac } from 'node:crypto';
2
- import { timingSafeStringEqual } from '../auth/password-hash.js';
3
- import { ACCOUNT_DEACTIVATED_MESSAGE } from '../auth/account-admission.js';
2
+ import { ACCOUNT_DEACTIVATED_MESSAGE, timingSafeStringEqual } from '../auth/index.js';
4
3
  import jwt from 'jsonwebtoken';
5
4
  import type { AuthUser, IGitService, IWorkflowService } from '@bevel-software/platform-shared';
6
5
  import { suggestionsBranchPrefixFor } from '@bevel-software/platform-shared';
@@ -8,9 +7,9 @@ import { logger } from '../../shared/logging.js';
8
7
  import { workspaceIdForBranch } from '../../shared/workspace-id.js';
9
8
  import type { KbContext } from '../../shared/kb-context.js';
10
9
  import type { IAccessControl } from '../access/access-control.interface.js';
11
- import { changeRequestLink, changeRequestLinkBase } from '../workflow/git/change-request-link.js';
12
- import type { FileReaderRegistry } from '../workspace/file-readers/file-reader.js';
13
- import type { WriteValidator } from '../kb-fs/locking-filesystem.js';
10
+ import { changeRequestLink, changeRequestLinkBase } from '../workflow/index.js';
11
+ import type { FileReaderRegistry } from '../workspace/index.js';
12
+ import type { WriteValidator } from '../kb-fs/index.js';
14
13
  import type {
15
14
  EmbedAuthPort,
16
15
  EmbedFileView,
@@ -21,6 +20,7 @@ import type {
21
20
  EmbedProposalResult,
22
21
  EmbedSubject,
23
22
  EmbedTokenResult,
23
+ EmbedViewer,
24
24
  IEmbedService,
25
25
  } from './embed.interface.js';
26
26
  import {
@@ -35,16 +35,24 @@ import type { IAccountLinkService } from './account-link.service.js';
35
35
  const log = logger('embed');
36
36
 
37
37
  /**
38
- * Token lifetime — long enough for a view left open in a chat, short enough
39
- * that a token copied out of a transcript stops working on its own. The
40
- * token rides in `open_page`'s result, so it sits in the chat transcript, and
41
- * whoever holds it reads and edits that one file as the token's user until
42
- * it expires. One hour (Razvan, 2026-10-09; the embed used two before): a
43
- * page left open in a chat is still covered, and the window is halved. A
44
- * view older than that shows the expired sentence and the agent opens the
45
- * page again.
38
+ * Lifetime of a token `open_page` mints (`kind: user`). The token rides in
39
+ * the tool's result, so it sits in the chat transcript, and whoever holds it
40
+ * reads and edits that one file as the token's user until it expires. Five
41
+ * minutes (Razvan, 2026-10-09; one hour before, two before that): a token
42
+ * copied out of a transcript stops working almost at once. A view left open
43
+ * longer is not ended by it — the MCP App view asks its host to call
44
+ * `open_page` again when a call is refused for the token, and carries on with
45
+ * the fresh one (see `mcp-app/page.html`).
46
46
  */
47
- export const EMBED_TOKEN_TTL_SECONDS = 60 * 60;
47
+ export const EMBED_USER_TOKEN_TTL_SECONDS = 5 * 60;
48
+
49
+ /**
50
+ * Lifetime of a token the Atlassian connector mints through the shared-secret
51
+ * route (`kind: atlassian`). One hour, unchanged: the connector frames
52
+ * `/embed` directly and has no host to renew a token through, and its mint
53
+ * contract does not change (Decision 9 of the parent specification).
54
+ */
55
+ export const EMBED_CONNECTOR_TOKEN_TTL_SECONDS = 60 * 60;
48
56
 
49
57
  /** What the embed's config needs from the deployment's. */
50
58
  export interface EmbedConfig {
@@ -148,7 +156,11 @@ export class EmbedService implements IEmbedService {
148
156
  if (!this.authService.isEmailDomainAllowed(email ?? '')) {
149
157
  throw new EmbedAccessError('Your email domain is not permitted to view this content');
150
158
  }
151
- return this.mint({ kind: 'atlassian', accountId: input.accountId }, input.reference);
159
+ const minted = await this.mint({ kind: 'atlassian', accountId: input.accountId }, input.reference);
160
+ // The connector's host sizes its frame to the content (the issue panel
161
+ // grows with the page), so its view is told to report its height; the
162
+ // MCP path says nothing, and its host's fixed reading pane stays.
163
+ return { ...minted, embedUrl: this.embedUrlFor(minted.token, { sizing: 'content' }) };
152
164
  }
153
165
 
154
166
  /**
@@ -195,13 +207,20 @@ export class EmbedService implements IEmbedService {
195
207
  repoRelative,
196
208
  slug: ref.slug,
197
209
  };
198
- const token = jwt.sign(claims, this.signingKey, { expiresIn: EMBED_TOKEN_TTL_SECONDS });
210
+ const expiresIn =
211
+ subject.kind === 'user' ? EMBED_USER_TOKEN_TTL_SECONDS : EMBED_CONNECTOR_TOKEN_TTL_SECONDS;
212
+ const token = jwt.sign(claims, this.signingKey, { expiresIn });
199
213
  return { token, embedUrl: this.embedUrlFor(token) };
200
214
  }
201
215
 
202
- /** The view's address — what a host frames. */
203
- embedUrlFor(token: string): string {
204
- return `${this.config.publicFrontendUrl}/embed?token=${encodeURIComponent(token)}`;
216
+ /**
217
+ * The view's address — what a host frames. `sizing: 'content'` tells the
218
+ * view its host sizes the frame to the content and wants the height
219
+ * reported; absent, the host's fixed pane is kept.
220
+ */
221
+ embedUrlFor(token: string, opts: { sizing?: 'content' } = {}): string {
222
+ const sizing = opts.sizing === 'content' ? '&sizing=content' : '';
223
+ return `${this.config.publicFrontendUrl}/embed?token=${encodeURIComponent(token)}${sizing}`;
205
224
  }
206
225
 
207
226
  /** A file's address in the app — what a link opens in a new tab. */
@@ -556,6 +575,12 @@ export class EmbedService implements IEmbedService {
556
575
  return { claims, user, wsPath: this.wsPathFor(claims.repoRelative) };
557
576
  }
558
577
 
578
+ async viewerOf(token: string): Promise<EmbedViewer> {
579
+ const claims = this.verifyToken(token);
580
+ const identity = await this.resolveIdentity(claims);
581
+ return { ...identity, repoRelative: claims.repoRelative };
582
+ }
583
+
559
584
  /** Resolve the token's identity to a user + read/write verdicts. */
560
585
  private async resolveIdentity(claims: EmbedClaims): Promise<ResolvedIdentity> {
561
586
  const unresolved: ResolvedIdentity = { linked: false, user: null, canRead: false, canWrite: false };
@@ -104,6 +104,10 @@ export function registerEmbedTools(
104
104
  type: 'object',
105
105
  properties: {
106
106
  path: { type: 'string', description: 'The path that was shown (echoes the input).' },
107
+ heading: {
108
+ type: 'string',
109
+ description: 'The heading the view opens at (echoes the input). Absent when none was named.',
110
+ },
107
111
  content: { type: 'string', description: "The page's text, exactly as `read_file` answers it." },
108
112
  embedUrl: {
109
113
  type: 'string',
@@ -163,12 +167,16 @@ export function registerEmbedTools(
163
167
  result.kind === 'text' ? result.text : result.kind === 'image' ? result.note : result.message;
164
168
 
165
169
  const appUrl = deps.appUrlFor(repoRelative, heading);
170
+ // The heading goes back beside the path: the view calls this tool again
171
+ // with both when its token runs out (see `mcp-app/page.html`), and a
172
+ // result is all it is told.
173
+ const echoed = heading ? { heading } : {};
166
174
  // No view on a plain-http deployment (Decision 8): a host's https
167
175
  // sandbox cannot load from it. The text and the app address are still
168
176
  // the answer, and the tool says why there is no view rather than
169
177
  // handing back an address no host can open.
170
178
  if (!deps.canBeReached()) {
171
- return { path: raw, content, appUrl, branch, note: HTTP_DEPLOYMENT_NOTE };
179
+ return { path: raw, ...echoed, content, appUrl, branch, note: HTTP_DEPLOYMENT_NOTE };
172
180
  }
173
181
  // Minted for the identity THIS MCP session authenticated — the
174
182
  // signed-in user, or the owner of the connection key the agent carries.
@@ -178,7 +186,7 @@ export function registerEmbedTools(
178
186
  userId: ctx.user.id,
179
187
  reference: heading ? `${repoRelative}#${heading}` : repoRelative,
180
188
  });
181
- return { path: raw, content, embedUrl, appUrl, branch };
189
+ return { path: raw, ...echoed, content, embedUrl, appUrl, branch };
182
190
  }),
183
191
  );
184
192
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * What the kb-fs module offers the other modules. The filesystem and the
3
+ * locks are wired by the composition root; a module that writes through the
4
+ * workspace needs only the shape of the gate every write passes first.
5
+ */
6
+ export type { WriteValidator } from './locking-filesystem.js';
@@ -1,11 +1,10 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { DEFAULT_BRANCH, joinBranchFor } from '@bevel-software/platform-shared';
3
- import {
4
- PluginJoinRequestJobs,
5
- JoinRequestNotReadyError,
6
- type JoinRequestJobsDeps,
7
- CLAIM_STALE_AFTER_MS,
8
- } from '../join-request-jobs.service.js';
3
+ import { DrizzleQueryError } from 'drizzle-orm';
4
+ import { PluginJoinRequestJobs, JoinRequestNotReadyError, type JoinRequestJobsDeps } from '../index.js';
5
+ // The claim window is not part of the module's surface: a test that moves
6
+ // the clock past it reads the number the service itself uses.
7
+ import { CLAIM_STALE_AFTER_MS } from '../join-request-jobs.service.js';
9
8
  import { workspaceIdForBranch } from '../../../shared/workspace-id.js';
10
9
  import { FakeJoinRequestStore } from './fake-join-request-store.js';
11
10
  import { testKbContext } from '../../../__tests__/kb-context.js';
@@ -146,6 +145,48 @@ describe('PluginJoinRequestJobs', () => {
146
145
  expect(row.failureReason).not.toContain('ghp_secret');
147
146
  });
148
147
 
148
+ it("logs a database failure as the database's reason, and shows the requester one stable sentence", async () => {
149
+ const h = harness();
150
+ // What Drizzle throws: its own error class, the SQL in the message, the
151
+ // driver's error as the cause.
152
+ h.workflow.openChangeRequest.mockRejectedValue(
153
+ new DrizzleQueryError(
154
+ 'insert into "change_requests" ("id", "number", "title", "body", "source_branch", "target_branch", "author_id", "status", "created_at", "updated_at") values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)',
155
+ [],
156
+ new Error('relation "change_requests" does not exist'),
157
+ ),
158
+ );
159
+ await h.jobs.start(pendingRow(h.store));
160
+
161
+ // The operator gets the reason, not the wrapper's cut-off SQL…
162
+ const logged = consoleError.mock.calls.map((c) => String(c[0]));
163
+ // (`printable` renders the reason as a JSON string, so its own quotes come escaped.)
164
+ expect(logged).toContainEqual(expect.stringMatching(/failed: "relation \\"change_requests\\" does not exist"/));
165
+ expect(logged.some((l) => l.includes('Failed query'))).toBe(false);
166
+ // …and the requester's card names no table.
167
+ const [row] = h.store.all();
168
+ expect(row.status).toBe('failed');
169
+ expect(row.failureReason).toBe('the request could not be recorded; an admin can find the reason in the server log');
170
+ });
171
+
172
+ it("shows the requester a wrapped failure's own words when it is not the database's", async () => {
173
+ const h = harness();
174
+ // The git work failing behind a wrapper of its own: the cause is what is
175
+ // logged, and what the requester reads, credentials stripped — the
176
+ // database's stable sentence would hide the detail they can act on.
177
+ h.workflow.openChangeRequest.mockRejectedValue(
178
+ new Error('opening the change request failed', {
179
+ cause: new Error('fatal: unable to access https://x:ghp_secret@github.com/acme/kb: could not resolve host'),
180
+ }),
181
+ );
182
+ await h.jobs.start(pendingRow(h.store));
183
+ const [row] = h.store.all();
184
+ expect(row.status).toBe('failed');
185
+ expect(row.failureReason).toContain('unable to access');
186
+ expect(row.failureReason).not.toContain('ghp_secret');
187
+ expect(row.failureReason).not.toContain('could not be recorded');
188
+ });
189
+
149
190
  it('fails the record rather than throwing when the plugin is gone', async () => {
150
191
  const h = harness({ target: async () => null });
151
192
  await expect(h.jobs.start(pendingRow(h.store))).resolves.toBeUndefined();
@@ -524,6 +565,32 @@ describe('PluginJoinRequestJobs', () => {
524
565
  expect(h.workflow.openChangeRequest).not.toHaveBeenCalled();
525
566
  });
526
567
 
568
+ it("a sweep that cannot read the table logs the database's reason, not the wrapper's cut-off SQL", async () => {
569
+ vi.useFakeTimers();
570
+ const warned = vi.spyOn(console, 'warn').mockImplementation(() => {});
571
+ try {
572
+ const h = harness();
573
+ // What Drizzle throws: its own error class, the SQL in the message, the driver's error underneath.
574
+ const wrapped = new DrizzleQueryError(
575
+ 'select "id", "requester_email", "requester_email_bidx", "requester_name", "plugin_key", "status", "failure_reason", "change_request_number", "claimed_at", "claim_token", "created_at" from "plugin_join_requests" where status = $1',
576
+ [],
577
+ new Error('Cannot use a pool after calling end on the pool'),
578
+ );
579
+ h.store.pending = async () => {
580
+ throw wrapped;
581
+ };
582
+ h.jobs.startSweeping(1000);
583
+ await vi.advanceTimersByTimeAsync(0);
584
+ h.jobs.stopSweeping();
585
+ const lines = warned.mock.calls.map((c) => String(c[0]));
586
+ expect(lines).toHaveLength(1);
587
+ expect(lines[0]).toContain('could not look at the join requests still owed: "Cannot use a pool after calling end on the pool"');
588
+ expect(lines[0]).not.toContain('Failed query');
589
+ } finally {
590
+ warned.mockRestore();
591
+ }
592
+ });
593
+
527
594
  it('proceeds past a createBranch failure only when the branch is really there', async () => {
528
595
  const h = harness();
529
596
  const branch = joinBranchFor(ALI, 'Finance');
@@ -4,6 +4,7 @@ import {
4
4
  type ChangeRequestState,
5
5
  type IWorkflowService,
6
6
  } from '@bevel-software/platform-shared';
7
+ import { DrizzleQueryError } from 'drizzle-orm';
7
8
  import { spliceGrant } from '../access-model/access-splice.js';
8
9
  import { sanitizeError } from '../workflow/sanitize-error.js';
9
10
  import { logger } from '../../shared/logging.js';
@@ -358,12 +359,12 @@ export class PluginJoinRequestJobs {
358
359
  */
359
360
  startSweeping(intervalMs: number = SWEEP_INTERVAL_MS): void {
360
361
  void this.sweep().catch((err: unknown) => {
361
- log.warn(`could not look at the join requests still owed: ${loggable(sanitizeError(err))}`);
362
+ log.warn(`could not look at the join requests still owed: ${loggable(sanitizeError(underlying(err)))}`);
362
363
  });
363
364
  if (this.sweepTimer) return;
364
365
  this.sweepTimer = setInterval(() => {
365
366
  void this.sweep().catch((err: unknown) => {
366
- log.warn(`could not look at the join requests still owed: ${loggable(sanitizeError(err))}`);
367
+ log.warn(`could not look at the join requests still owed: ${loggable(sanitizeError(underlying(err)))}`);
367
368
  });
368
369
  }, intervalMs);
369
370
  // Never a reason for the process to stay alive: every row is durable, and
@@ -398,7 +399,7 @@ export class PluginJoinRequestJobs {
398
399
  // wrap the whole attempt: the claim has to be refreshed for as long as
399
400
  // the work runs, and the work is the part that can take minutes.
400
401
  const live = await this.store.claim(record.id, CLAIM_STALE_AFTER_MS).catch((err: unknown) => {
401
- log.warn(`could not claim a join request: ${loggable(sanitizeError(err))}`);
402
+ log.warn(`could not claim a join request: ${loggable(sanitizeError(underlying(err)))}`);
402
403
  return null;
403
404
  });
404
405
  if (!live) {
@@ -426,7 +427,7 @@ export class PluginJoinRequestJobs {
426
427
  if (!still) held = false;
427
428
  return still ? 'held' : 'lost';
428
429
  } catch (err: unknown) {
429
- log.warn(`could not refresh a join-request claim: ${loggable(sanitizeError(err))}`);
430
+ log.warn(`could not refresh a join-request claim: ${loggable(sanitizeError(underlying(err)))}`);
430
431
  return 'unknown';
431
432
  }
432
433
  };
@@ -453,7 +454,10 @@ export class PluginJoinRequestJobs {
453
454
  if (state !== 'held') throw new ClaimLostError(state);
454
455
  });
455
456
  } catch (err) {
456
- const reason = sanitizeError(err);
457
+ // The cause when there is one (see `underlying`), for the log; a
458
+ // database's reason, where a wrapper's cut-off column list is not.
459
+ const cause = underlying(err);
460
+ const reason = sanitizeError(cause);
457
461
  // `pluginKey` is a persisted folder path, so it is escaped before it
458
462
  // reaches a log line: a folder carrying control characters could
459
463
  // otherwise write newlines of its own into the log.
@@ -473,22 +477,31 @@ export class PluginJoinRequestJobs {
473
477
  // claim held by a process that never really started.
474
478
  log.warn(`join request for ${key} postponed: ${loggable(reason)}`);
475
479
  await this.store.release(record.id, live.claimToken).catch((releaseErr: unknown) => {
476
- log.warn(`could not release that claim: ${loggable(sanitizeError(releaseErr))}`);
480
+ log.warn(`could not release that claim: ${loggable(sanitizeError(underlying(releaseErr)))}`);
477
481
  });
478
482
  return;
479
483
  }
480
484
  log.error(`join request for ${key} failed: ${loggable(reason)}`);
485
+ // What the requester reads on their card. The git work's own words
486
+ // are theirs to see (credentials stripped); a database's reason is
487
+ // not — it names tables and columns and is for the operator, who has
488
+ // it in the line above — so a database failure is recorded in one
489
+ // stable sentence. The database's failures are the ones the query
490
+ // layer wraps (`DrizzleQueryError`, its cause the driver's error);
491
+ // any other wrapped error keeps its own sanitised words, which may be
492
+ // what the requester needs to act on.
493
+ const shown = err instanceof DrizzleQueryError ? DATABASE_FAILURE_REASON : reason;
481
494
  await this.store
482
495
  // Named with the token, so this lands only if the row is still this
483
496
  // process's. A worker that overran the window and had its row taken
484
497
  // writes nothing here rather than stamping its failure on the attempt
485
498
  // that replaced it — which would show the requester a failure for
486
499
  // work that is, at that moment, succeeding.
487
- .markFailed(record.id, live.claimToken, reason)
500
+ .markFailed(record.id, live.claimToken, shown)
488
501
  // Nothing left to do if even that write fails: the row stays
489
502
  // `pending` and the next boot's sweep tries the whole thing again.
490
503
  .catch((writeErr: unknown) => {
491
- log.error(`could not record that failure: ${loggable(sanitizeError(writeErr))}`);
504
+ log.error(`could not record that failure: ${loggable(sanitizeError(underlying(writeErr)))}`);
492
505
  });
493
506
  } finally {
494
507
  held = false;
@@ -669,6 +682,30 @@ function loggable(reason: string): string {
669
682
  return printable(reason);
670
683
  }
671
684
 
685
+ /**
686
+ * The error behind a wrapped one. Drizzle reports a failed query as its own
687
+ * error whose message is the SQL and whose `cause` is the database's reason;
688
+ * the sanitizer caps a message at a couple of hundred characters, so logging
689
+ * the wrapper showed a cut-off column list and never the reason. The reason
690
+ * is what every LOG line this service writes needs — "the pool has been
691
+ * ended", "relation does not exist" — the sweep's warning, the claim and
692
+ * heartbeat warnings, the failure line. The requester's card is the one
693
+ * place it does not go: a wrapped failure is recorded there as
694
+ * `DATABASE_FAILURE_REASON`, since a table name is the operator's to read.
695
+ * An error with no cause (git's, the claim's own) is reported as it is.
696
+ */
697
+ function underlying(err: unknown): unknown {
698
+ const cause = typeof err === 'object' && err !== null ? (err as { cause?: unknown }).cause : undefined;
699
+ return cause instanceof Error ? cause : err;
700
+ }
701
+
702
+ /**
703
+ * The sentence a requester's card carries when the database, not the git
704
+ * work, refused the request: stable, and naming no table or column. The
705
+ * database's own words go to the server log beside it.
706
+ */
707
+ const DATABASE_FAILURE_REASON = 'the request could not be recorded; an admin can find the reason in the server log';
708
+
672
709
  /** Node's "there is no such file" errors, and only those. */
673
710
  function isFileAbsent(err: unknown): boolean {
674
711
  const code = (err as NodeJS.ErrnoException | null)?.code;
@@ -154,7 +154,7 @@ describe('the skill tools read a branch when asked', () => {
154
154
  skills: { name: string; unmerged?: boolean }[];
155
155
  };
156
156
  expect(listArgs).toEqual([['e@x', { branch: undefined }]]);
157
- expect(body.skills).toEqual([{ name: 'rfi', description: 'RFI.', path: released.path }]);
157
+ expect(body.skills).toEqual([{ name: 'rfi', description: 'RFI.', path: released.path, canWrite: false }]);
158
158
  });
159
159
 
160
160
  it('a blank branch is no branch at all: the released catalog answers', async () => {
@@ -52,6 +52,10 @@ export interface SkillSummary {
52
52
  unmerged?: true;
53
53
  /** With `unmerged`: the branch the skill was read from. */
54
54
  branch?: string;
55
+ // No `canWrite` here, on purpose: whether the caller may change a skill
56
+ // directly is a per-caller verdict the agent tools add onto a COPY of each
57
+ // summary (`skills.tools.ts`). The catalog is cached across users, so a
58
+ // verdict stored on it would be served to the next caller.
55
59
  }
56
60
 
57
61
  export interface Skill extends SkillSummary {