@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.
- package/agent-guide/access-control.md +15 -0
- package/agent-guide/skills.md +8 -0
- package/dist/core/catalog-revision.d.ts.map +1 -1
- package/dist/core/catalog-revision.js +4 -2
- package/dist/core/catalog-revision.js.map +1 -1
- package/dist/core/compose-embed.d.ts +35 -0
- package/dist/core/compose-embed.d.ts.map +1 -0
- package/dist/core/compose-embed.js +14 -0
- package/dist/core/compose-embed.js.map +1 -0
- package/dist/core/core-ports.d.ts +10 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +7 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +1 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +25 -14
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +11 -6
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +13 -6
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +5 -2
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +36 -14
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +208 -85
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/auth/index.d.ts +15 -0
- package/dist/modules/auth/index.d.ts.map +1 -0
- package/dist/modules/auth/index.js +20 -0
- package/dist/modules/auth/index.js.map +1 -0
- package/dist/modules/embed/embed.interface.d.ts +22 -0
- package/dist/modules/embed/embed.interface.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.d.ts +28 -14
- package/dist/modules/embed/embed.service.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.js +38 -17
- package/dist/modules/embed/embed.service.js.map +1 -1
- package/dist/modules/embed/embed.tools.d.ts.map +1 -1
- package/dist/modules/embed/embed.tools.js +10 -2
- package/dist/modules/embed/embed.tools.js.map +1 -1
- package/dist/modules/kb-fs/index.d.ts +7 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -0
- package/dist/modules/kb-fs/index.js +2 -0
- package/dist/modules/kb-fs/index.js.map +1 -0
- package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-request-jobs.service.js +43 -8
- package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts +8 -1
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +115 -11
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
- package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
- package/dist/modules/tool-helpers/default-branch-write.js +63 -0
- package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
- package/dist/modules/tool-helpers/index.d.ts +1 -0
- package/dist/modules/tool-helpers/index.d.ts.map +1 -1
- package/dist/modules/tool-helpers/index.js +1 -0
- package/dist/modules/tool-helpers/index.js.map +1 -1
- package/dist/modules/workflow/file-lock.service.d.ts +27 -5
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +70 -18
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +13 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +24 -0
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/index.d.ts +8 -0
- package/dist/modules/workflow/index.d.ts.map +1 -0
- package/dist/modules/workflow/index.js +8 -0
- package/dist/modules/workflow/index.js.map +1 -0
- package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +4 -24
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +20 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +5 -3
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/index.d.ts +10 -0
- package/dist/modules/workspace/index.d.ts.map +1 -0
- package/dist/modules/workspace/index.js +10 -0
- package/dist/modules/workspace/index.js.map +1 -0
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +106 -20
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/workspace-id.d.ts +17 -0
- package/dist/shared/workspace-id.d.ts.map +1 -1
- package/dist/shared/workspace-id.js +24 -0
- package/dist/shared/workspace-id.js.map +1 -1
- package/kb-template/access.md +2 -1
- package/mcp-app/page.html +142 -8
- package/package.json +3 -3
- package/src/core/__tests__/compose-embed.test.ts +65 -0
- package/src/core/__tests__/lifecycle.test.ts +29 -26
- package/src/core/catalog-revision.ts +4 -2
- package/src/core/compose-embed.ts +50 -0
- package/src/core/core-ports.ts +10 -0
- package/src/core/create-core-server.ts +7 -1
- package/src/core/create-core-services.ts +27 -21
- package/src/core/lifecycle.ts +24 -12
- package/src/modules/access/__tests__/access-control.admin-root-floor.test.ts +23 -3
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +303 -25
- package/src/modules/access/__tests__/access-control.download-read.test.ts +3 -1
- package/src/modules/access/__tests__/access-control.service.test.ts +77 -16
- package/src/modules/access/__tests__/change-read-gate.test.ts +36 -5
- package/src/modules/access/access-control.interface.ts +5 -2
- package/src/modules/access/access-control.service.ts +236 -95
- package/src/modules/auth/index.ts +29 -0
- package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
- package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
- package/src/modules/embed/embed.interface.ts +24 -0
- package/src/modules/embed/embed.service.ts +44 -19
- package/src/modules/embed/embed.tools.ts +10 -2
- package/src/modules/kb-fs/index.ts +6 -0
- package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
- package/src/modules/plugins/join-request-jobs.service.ts +45 -8
- package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
- package/src/modules/skills/skills.contract.ts +4 -0
- package/src/modules/skills/skills.tools.ts +120 -10
- package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
- package/src/modules/tool-helpers/default-branch-write.ts +90 -0
- package/src/modules/tool-helpers/index.ts +5 -0
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +36 -2
- package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
- package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
- package/src/modules/workflow/file-lock.service.ts +79 -18
- package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
- package/src/modules/workflow/git/git.service.ts +23 -0
- package/src/modules/workflow/index.ts +7 -0
- package/src/modules/workflow/pending-commits.service.ts +4 -23
- package/src/modules/workflow/workflow.routes.ts +24 -1
- package/src/modules/workflow/workflow.service.ts +3 -2
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +21 -11
- package/src/modules/workspace/index.ts +14 -0
- package/src/modules/workspace/workspace.tools.ts +120 -20
- 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
|
-
*
|
|
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
|
|
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/
|
|
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/
|
|
12
|
-
import type { FileReaderRegistry } from '../workspace/
|
|
13
|
-
import type { WriteValidator } from '../kb-fs/
|
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* page
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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,
|
|
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 {
|