@bevel-software/platform-core-backend 0.7.5 → 0.8.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/LICENSE +202 -202
- package/THIRD-PARTY-NOTICES.md +428 -454
- package/dist/core/core-ports.d.ts +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +51 -7
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +5 -3
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +30 -18
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +8 -7
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +1 -1
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +6 -6
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts +5 -5
- package/dist/modules/access/access-declarations.js +3 -3
- package/dist/modules/access/access-mutation.service.d.ts +3 -3
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +3 -3
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.js +4 -4
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.js +19 -19
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/creator-access.d.ts +2 -2
- package/dist/modules/access/creator-access.js +5 -5
- package/dist/modules/access/creator-access.js.map +1 -1
- package/dist/modules/access/roles-admin.service.d.ts +18 -4
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +14 -4
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/code-mode/code-mode-names.d.ts +5 -13
- package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode-names.js +5 -27
- package/dist/modules/code-mode/code-mode-names.js.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
- package/dist/modules/code-mode/code-mode.tool.js +29 -7
- package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +11 -52
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +33 -395
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/plugins/index.d.ts +7 -0
- package/dist/modules/plugins/index.d.ts.map +1 -0
- package/dist/modules/plugins/index.js +6 -0
- package/dist/modules/plugins/index.js.map +1 -0
- package/dist/modules/plugins/join-proposals.d.ts +53 -0
- package/dist/modules/plugins/join-proposals.d.ts.map +1 -0
- package/dist/modules/plugins/join-proposals.js +67 -0
- package/dist/modules/plugins/join-proposals.js.map +1 -0
- package/dist/modules/plugins/join-requests.service.d.ts +81 -0
- package/dist/modules/plugins/join-requests.service.d.ts.map +1 -0
- package/dist/modules/plugins/join-requests.service.js +135 -0
- package/dist/modules/plugins/join-requests.service.js.map +1 -0
- package/dist/modules/plugins/plugin-provision.service.d.ts +134 -0
- package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -0
- package/dist/modules/plugins/plugin-provision.service.js +344 -0
- package/dist/modules/plugins/plugin-provision.service.js.map +1 -0
- package/dist/modules/plugins/plugins.contract.d.ts +106 -0
- package/dist/modules/plugins/plugins.contract.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.contract.js +36 -0
- package/dist/modules/plugins/plugins.contract.js.map +1 -0
- package/dist/modules/plugins/plugins.routes.d.ts +42 -0
- package/dist/modules/plugins/plugins.routes.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.routes.js +379 -0
- package/dist/modules/plugins/plugins.routes.js.map +1 -0
- package/dist/modules/plugins/plugins.service.d.ts +60 -0
- package/dist/modules/plugins/plugins.service.d.ts.map +1 -0
- package/dist/modules/plugins/plugins.service.js +172 -0
- package/dist/modules/plugins/plugins.service.js.map +1 -0
- package/dist/modules/skills/pending-skills.service.d.ts +2 -2
- package/dist/modules/skills/pending-skills.service.js +7 -7
- package/dist/modules/skills/pending-skills.service.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts +4 -4
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.contract.js +1 -1
- package/dist/modules/skills/skills.service.js +5 -5
- package/dist/modules/skills/skills.service.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +65 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.js +276 -0
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +92 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +328 -0
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +38 -12
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +13 -2
- package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.routes.js +233 -2
- package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +74 -37
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -3
- package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
- package/dist/modules/workflow/git/git.service.js +2 -2
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workspace/kb-seed.service.d.ts +2 -2
- package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
- package/dist/modules/workspace/kb-seed.service.js +43 -10
- package/dist/modules/workspace/kb-seed.service.js.map +1 -1
- package/dist/modules/workspace/plugins-migration.d.ts +50 -0
- package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
- package/dist/modules/workspace/plugins-migration.js +379 -0
- package/dist/modules/workspace/plugins-migration.js.map +1 -0
- package/dist/modules/workspace/workspace.routes.js +3 -3
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +26 -0
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +83 -12
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/shared/kb-layout.test.js +3 -3
- package/dist/shared/kb-layout.test.js.map +1 -1
- package/dist/shared/utcp-namespace.d.ts +6 -27
- package/dist/shared/utcp-namespace.d.ts.map +1 -1
- package/dist/shared/utcp-namespace.js +6 -63
- package/dist/shared/utcp-namespace.js.map +1 -1
- package/dist/shared/variable-refs.d.ts +42 -0
- package/dist/shared/variable-refs.d.ts.map +1 -0
- package/dist/shared/variable-refs.js +60 -0
- package/dist/shared/variable-refs.js.map +1 -0
- package/kb-template/.bevelignore +1 -1
- package/kb-template/AGENTS.md +88 -35
- package/kb-template/KnowledgeBase/How to get started.md +10 -10
- package/kb-template/access.md +36 -36
- package/migrations/meta/0000_snapshot.json +1479 -1479
- package/package.json +5 -4
- package/src/assets.ts +25 -25
- package/src/core/core-ports.ts +106 -106
- package/src/core/create-core-server.ts +55 -9
- package/src/core/create-core-services.ts +40 -20
- package/src/index.ts +69 -69
- package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
- package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
- package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
- package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
- package/src/modules/access/__tests__/access-splice.test.ts +2 -2
- package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
- package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
- package/src/modules/access/__tests__/roles-admin.service.test.ts +13 -1
- package/src/modules/access/access-control.interface.ts +8 -7
- package/src/modules/access/access-control.service.ts +7 -7
- package/src/modules/access/access-declarations.ts +5 -5
- package/src/modules/access/access-mutation.service.ts +3 -3
- package/src/modules/access/access-splice.ts +4 -4
- package/src/modules/access/access.routes.ts +20 -20
- package/src/modules/access/creator-access.ts +5 -5
- package/src/modules/access/roles-admin.service.ts +13 -2
- package/src/modules/admin/admin-access.routes.ts +29 -29
- package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
- package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
- package/src/modules/auth/rate-limit.ts +45 -45
- package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
- package/src/modules/code-mode/code-mode-names.ts +10 -36
- package/src/modules/code-mode/code-mode.tool.ts +27 -7
- package/src/modules/database/connection.ts +15 -15
- package/src/modules/database/schema.ts +11 -11
- package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
- package/src/modules/mcp/mcp.service.ts +57 -435
- package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
- package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
- package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
- package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
- package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
- package/src/modules/plugins/index.ts +17 -0
- package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
- package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
- package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
- package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
- package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
- package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
- package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
- package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
- package/src/modules/skills/pending-skills.service.ts +7 -7
- package/src/modules/skills/skills.contract.ts +4 -4
- package/src/modules/skills/skills.service.ts +5 -5
- package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
- package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
- package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
- package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
- package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
- package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
- package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
- package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
- package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
- package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
- package/src/modules/workflow/git/git.service.ts +2 -2
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
- package/src/modules/workflow/workflow-hooks.ts +101 -101
- package/src/modules/workspace/__tests__/kb-seed.service.test.ts +81 -13
- package/src/modules/workspace/__tests__/plugins-migration.test.ts +427 -0
- package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
- package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
- package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
- package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
- package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
- package/src/modules/workspace/__tests__/workspace.service.test.ts +116 -0
- package/src/modules/workspace/bevel-ignore.ts +66 -66
- package/src/modules/workspace/kb-seed.service.ts +38 -9
- package/src/modules/workspace/plugins-migration.ts +479 -0
- package/src/modules/workspace/session-sink.ts +25 -25
- package/src/modules/workspace/workspace.routes.ts +3 -3
- package/src/modules/workspace/workspace.service.ts +85 -14
- package/src/modules/workspace/workspace.tools.ts +922 -922
- package/src/shared/__tests__/join-request.test.ts +13 -13
- package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
- package/src/shared/kb-layout.test.ts +3 -3
- package/src/shared/utcp-namespace.ts +10 -68
- package/src/shared/variable-refs.ts +64 -0
- package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
- package/src/modules/groups/index.ts +0 -17
- package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
- /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts}
RENAMED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* folder into existence, and (via {@link
|
|
2
|
+
* Plugin provisioning — the ONE privileged door for bringing a `Plugins/<name>/`
|
|
3
|
+
* folder into existence, and (via {@link PluginProvisionService.deletePlugin})
|
|
4
4
|
* for taking one back out of it.
|
|
5
5
|
*
|
|
6
|
-
* Creating a
|
|
6
|
+
* Creating a plugin is the single operation the access model cannot govern
|
|
7
7
|
* from inside: the folder that will carry the rules does not exist yet, and
|
|
8
8
|
* the root's own rule (`write: Admin`) says no. The old answer was a
|
|
9
9
|
* carve-out inside the generic write gate — every write path could claim an
|
|
10
|
-
* unused name under `
|
|
10
|
+
* unused name under `Plugins/`, and the gate had to re-derive "is this that
|
|
11
11
|
* one blessed case?" on every check. This service replaces that: the generic
|
|
12
12
|
* gate is uniformly strict again, and the privilege lives here, named,
|
|
13
13
|
* behind its own endpoint.
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
*
|
|
22
22
|
* Two shapes, two templates:
|
|
23
23
|
*
|
|
24
|
-
* - A NAMED
|
|
25
|
-
* reads `everyone` — anyone may open the FILE, see the
|
|
24
|
+
* - A NAMED plugin: discoverable by design. The access.md's own frontmatter
|
|
25
|
+
* reads `everyone` — anyone may open the FILE, see the plugin listed, and
|
|
26
26
|
* ask to join — while the BODY (the folder's actual rules) names only
|
|
27
27
|
* the creator under read, write and owner.
|
|
28
28
|
* - A PERSONAL folder (`personal-<user-id>`): private by design. No
|
|
@@ -36,10 +36,13 @@ import path from 'node:path';
|
|
|
36
36
|
|
|
37
37
|
import {
|
|
38
38
|
DEFAULT_BRANCH,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
PLUGINS_DIR,
|
|
40
|
+
PLUGIN_MANIFEST_FILE,
|
|
41
|
+
pluginManifestName,
|
|
42
|
+
renderPluginManifest,
|
|
43
|
+
PERSONAL_PLUGIN_PREFIX,
|
|
44
|
+
isPersonalPluginFolder,
|
|
45
|
+
personalPluginFolderName,
|
|
43
46
|
type AuthUser,
|
|
44
47
|
} from '@bevel-software/platform-shared';
|
|
45
48
|
import type { WorkspaceService } from '../workspace/workspace.service.js';
|
|
@@ -59,31 +62,32 @@ export interface ProvisionCommitDriver {
|
|
|
59
62
|
): Promise<void>;
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
export interface
|
|
63
|
-
/** The folder name under `
|
|
65
|
+
export interface ProvisionedPlugin {
|
|
66
|
+
/** The folder name under `Plugins/` (not the full path). */
|
|
64
67
|
folder: string;
|
|
65
68
|
/** False when an ensure found the folder already there. */
|
|
66
69
|
created: boolean;
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
/** A refusal the route can pass through: message + HTTP status. */
|
|
70
|
-
export class
|
|
73
|
+
export class PluginProvisionError extends Error {
|
|
71
74
|
constructor(
|
|
72
75
|
message: string,
|
|
73
76
|
readonly status: number,
|
|
74
77
|
) {
|
|
75
78
|
super(message);
|
|
76
|
-
this.name = '
|
|
79
|
+
this.name = 'PluginProvisionError';
|
|
77
80
|
}
|
|
78
81
|
}
|
|
79
82
|
|
|
80
|
-
export class
|
|
83
|
+
export class PluginProvisionService {
|
|
81
84
|
/**
|
|
82
|
-
* Serialises creations
|
|
83
|
-
* arbitrates same-path races,
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
85
|
+
* Serialises creations and deletions by MANIFEST SLUG
|
|
86
|
+
* (`pluginManifestName(name)`). The wx write arbitrates same-path races,
|
|
87
|
+
* but the identity the collision checks defend is the slug: case-variants
|
|
88
|
+
* (`GTM`/`gtm`) and distinct spellings (`Sales Team`/`Sales-Team`) all
|
|
89
|
+
* derive the same key, so no two requests that would publish one manifest
|
|
90
|
+
* name can hold the lock at once.
|
|
87
91
|
*/
|
|
88
92
|
private readonly creations = new WorkspaceMutex();
|
|
89
93
|
|
|
@@ -96,37 +100,62 @@ export class GroupProvisionService {
|
|
|
96
100
|
) {}
|
|
97
101
|
|
|
98
102
|
/**
|
|
99
|
-
* Create `
|
|
103
|
+
* Create `Plugins/<name>/` for `user`. Throws `PluginProvisionError` 422 on a
|
|
100
104
|
* name the filesystem or the model cannot carry, 409 when the name is taken
|
|
101
105
|
* (case-insensitively — the workspaces live on case-insensitive
|
|
102
106
|
* filesystems too, where `GTM` and `gtm` are one folder).
|
|
103
107
|
*/
|
|
104
|
-
async
|
|
108
|
+
async createPlugin(user: AuthUser, rawName: string): Promise<ProvisionedPlugin> {
|
|
105
109
|
const name = rawName.trim();
|
|
106
|
-
if (!name) throw new
|
|
110
|
+
if (!name) throw new PluginProvisionError('A plugin needs a name.', 422);
|
|
107
111
|
// eslint-disable-next-line no-control-regex -- NUL and control chars are
|
|
108
112
|
// exactly what a filesystem path cannot carry; refusing them here keeps
|
|
109
113
|
// the refusal a 422 instead of the fs layer's 500.
|
|
110
114
|
if (/[/\\\u0000-\u001f\u007f]/.test(name) || name === '.' || name === '..' || name.startsWith('.')) {
|
|
111
|
-
throw new
|
|
112
|
-
'A
|
|
115
|
+
throw new PluginProvisionError(
|
|
116
|
+
'A plugin name can\'t contain / or \\ or control characters, or start with a dot.',
|
|
113
117
|
422,
|
|
114
118
|
);
|
|
115
119
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
// Reserved BY SLUG, which subsumes the folder-name spelling: personal
|
|
121
|
+
// folders publish manifests like any plugin, so "Personal Abc" (slug
|
|
122
|
+
// `personal-abc`) squats the namespace exactly as "personal-abc" would —
|
|
123
|
+
// a name-only check let it through.
|
|
124
|
+
if (pluginManifestName(name).startsWith(PERSONAL_PLUGIN_PREFIX)) {
|
|
125
|
+
// The message names the DERIVED slug: for a spelling like "Personal
|
|
126
|
+
// Abc" the reservation is invisible in the name itself, and a refusal
|
|
127
|
+
// the user can't trace to their input is a refusal they can't fix.
|
|
128
|
+
throw new PluginProvisionError(
|
|
129
|
+
`"${name}" would publish the manifest name "${pluginManifestName(name)}" — ` +
|
|
130
|
+
`the "${PERSONAL_PLUGIN_PREFIX}" namespace is reserved for personal folders. Pick another name.`,
|
|
121
131
|
422,
|
|
122
132
|
);
|
|
123
133
|
}
|
|
124
|
-
|
|
134
|
+
// Locked on the manifest SLUG, not the lowercased folder: the slug is the
|
|
135
|
+
// identity the twin check below defends, and two spellings that collide
|
|
136
|
+
// on it ("Sales Team" / "Sales-Team") must take the SAME lock or both
|
|
137
|
+
// pass the check concurrently. Case-variants share a slug too, so this
|
|
138
|
+
// key subsumes the old lowercase one — and deletion (below) derives its
|
|
139
|
+
// key the same way, keeping delete/re-create of one name serialized.
|
|
140
|
+
return this.creations.run(`plugin:${pluginManifestName(name)}`, async () => {
|
|
125
141
|
const existing = await this.existingFolder(name);
|
|
126
142
|
if (existing !== null) {
|
|
127
|
-
throw new
|
|
143
|
+
throw new PluginProvisionError(`A plugin named "${existing}" already exists.`, 409);
|
|
128
144
|
}
|
|
129
|
-
|
|
145
|
+
// Folder uniqueness is not manifest uniqueness: the manifest `name` is
|
|
146
|
+
// a LOSSY slug of the folder (`Sales Team` and `Sales-Team` both
|
|
147
|
+
// become `sales-team`), and it is the identity a conformant client
|
|
148
|
+
// keys plugins on — two folders sharing it would be two plugins one
|
|
149
|
+
// key, with no telling which a client resolves.
|
|
150
|
+
const slugTwin = await this.manifestNameTwin(name);
|
|
151
|
+
if (slugTwin !== null) {
|
|
152
|
+
throw new PluginProvisionError(
|
|
153
|
+
`"${name}" and the existing plugin "${slugTwin}" would share the manifest name ` +
|
|
154
|
+
`"${pluginManifestName(name)}" — pick a more distinct name.`,
|
|
155
|
+
409,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
await this.provision(user, name, pluginAccessMd(user));
|
|
130
159
|
return { folder: name, created: true };
|
|
131
160
|
});
|
|
132
161
|
}
|
|
@@ -135,9 +164,9 @@ export class GroupProvisionService {
|
|
|
135
164
|
* Ensure the caller's personal folder exists — idempotent, keyed to the
|
|
136
165
|
* stable user id. Returns `created: false` when it is already there.
|
|
137
166
|
*/
|
|
138
|
-
async
|
|
139
|
-
const folder =
|
|
140
|
-
return this.creations.run(`
|
|
167
|
+
async ensurePersonalPlugin(user: AuthUser): Promise<ProvisionedPlugin> {
|
|
168
|
+
const folder = personalPluginFolderName(user.id);
|
|
169
|
+
return this.creations.run(`plugin:${pluginManifestName(folder)}`, async () => {
|
|
141
170
|
if ((await this.existingFolder(folder)) !== null) {
|
|
142
171
|
return { folder, created: false };
|
|
143
172
|
}
|
|
@@ -147,7 +176,7 @@ export class GroupProvisionService {
|
|
|
147
176
|
// ENSURE semantics even under a race the lock cannot see (another
|
|
148
177
|
// process, a checkout that appeared between check and write): the
|
|
149
178
|
// folder existing is this method's success case, never its error.
|
|
150
|
-
if (err instanceof
|
|
179
|
+
if (err instanceof PluginProvisionError && err.status === 409) {
|
|
151
180
|
return { folder, created: false };
|
|
152
181
|
}
|
|
153
182
|
throw err;
|
|
@@ -157,10 +186,10 @@ export class GroupProvisionService {
|
|
|
157
186
|
}
|
|
158
187
|
|
|
159
188
|
/**
|
|
160
|
-
* Delete `
|
|
189
|
+
* Delete `Plugins/<name>/` — the whole folder, its skills and tools
|
|
161
190
|
* included, in ONE commit. MECHANISM only: the route owns the
|
|
162
191
|
* authorization (the caller must hold the `owner` verb on the folder;
|
|
163
|
-
* this service never re-derives it), exactly as `
|
|
192
|
+
* this service never re-derives it), exactly as `createPlugin` leaves
|
|
164
193
|
* "any signed-in user" to its endpoint.
|
|
165
194
|
*
|
|
166
195
|
* Shape mirrors a provision run in reverse, with the same failure
|
|
@@ -168,33 +197,34 @@ export class GroupProvisionService {
|
|
|
168
197
|
* scanners ignore) rather than removed, the deletion is committed
|
|
169
198
|
* synchronously, and only a landed commit lets the parked bytes go. A
|
|
170
199
|
* refused commit renames the folder back, so a failed delete leaves the
|
|
171
|
-
*
|
|
200
|
+
* plugin exactly as it was — never half-gone on disk while origin still
|
|
172
201
|
* carries it.
|
|
173
202
|
*
|
|
174
|
-
* Serialised on the same
|
|
175
|
-
* never interleave with a re-creation of the same name
|
|
203
|
+
* Serialised on the same slug-keyed lock creations use, so a delete can
|
|
204
|
+
* never interleave with a re-creation of the same name (the key derives
|
|
205
|
+
* from the name, so both spell it identically).
|
|
176
206
|
*/
|
|
177
|
-
async
|
|
207
|
+
async deletePlugin(user: AuthUser, rawName: string): Promise<void> {
|
|
178
208
|
const name = rawName.trim();
|
|
179
|
-
if (!name) throw new
|
|
180
|
-
if (
|
|
181
|
-
// Personal folders are not
|
|
182
|
-
// nobody deletes somebody's private shelf through the
|
|
183
|
-
throw new
|
|
209
|
+
if (!name) throw new PluginProvisionError('A plugin needs a name.', 422);
|
|
210
|
+
if (isPersonalPluginFolder(name)) {
|
|
211
|
+
// Personal folders are not plugins (the catalog never lists them), and
|
|
212
|
+
// nobody deletes somebody's private shelf through the plugin door.
|
|
213
|
+
throw new PluginProvisionError('Unknown plugin', 404);
|
|
184
214
|
}
|
|
185
|
-
return this.creations.run(`
|
|
215
|
+
return this.creations.run(`plugin:${pluginManifestName(name)}`, async () => {
|
|
186
216
|
const existing = await this.existingFolder(name);
|
|
187
217
|
// Exact match only — the catalog hands the route the on-disk casing,
|
|
188
|
-
// so a mismatch means the
|
|
189
|
-
if (existing !== name) throw new
|
|
218
|
+
// so a mismatch means the plugin is gone (or was never there).
|
|
219
|
+
if (existing !== name) throw new PluginProvisionError('Unknown plugin', 404);
|
|
190
220
|
|
|
191
221
|
const wsId = await this.readyWorkspaceId();
|
|
192
222
|
const wsDir = await this.workspaceService.getWorkspacePath(wsId);
|
|
193
|
-
const
|
|
194
|
-
const folderDir = path.join(
|
|
195
|
-
// Dot-prefixed ⇒ invisible to the
|
|
223
|
+
const pluginsDir = path.join(wsDir, this.kbDirName, PLUGINS_DIR);
|
|
224
|
+
const folderDir = path.join(pluginsDir, name);
|
|
225
|
+
// Dot-prefixed ⇒ invisible to the plugin scanner and the collision
|
|
196
226
|
// check for the whole window the commit is in flight.
|
|
197
|
-
const parkedDir = path.join(
|
|
227
|
+
const parkedDir = path.join(pluginsDir, `.${name}.deleting`);
|
|
198
228
|
|
|
199
229
|
await fs.rm(parkedDir, { recursive: true, force: true }); // a stale park from a crashed run
|
|
200
230
|
await fs.rename(folderDir, parkedDir);
|
|
@@ -205,17 +235,17 @@ export class GroupProvisionService {
|
|
|
205
235
|
// push gate — which would re-read an access.md this very commit
|
|
206
236
|
// removes — is skipped for exactly this commit. `commitFile` is
|
|
207
237
|
// path-scoped (`git add -- <path>`), and a folder path stages every
|
|
208
|
-
// deletion under it: one commit, one removed
|
|
238
|
+
// deletion under it: one commit, one removed plugin.
|
|
209
239
|
await this.commits.runPendingCommit(
|
|
210
240
|
wsId,
|
|
211
241
|
DEFAULT_BRANCH,
|
|
212
|
-
`${this.kbDirName}/${
|
|
242
|
+
`${this.kbDirName}/${PLUGINS_DIR}/${name}`,
|
|
213
243
|
user,
|
|
214
244
|
{ systemAuthorized: true },
|
|
215
245
|
);
|
|
216
246
|
} catch (err) {
|
|
217
247
|
// The commit did not land: put the folder back, so a failed delete
|
|
218
|
-
// is a no-op rather than a
|
|
248
|
+
// is a no-op rather than a plugin that exists at origin but not here.
|
|
219
249
|
try {
|
|
220
250
|
await fs.rename(parkedDir, folderDir);
|
|
221
251
|
} catch {
|
|
@@ -237,28 +267,67 @@ export class GroupProvisionService {
|
|
|
237
267
|
const wsDir = await this.workspaceService.getWorkspacePath(wsId);
|
|
238
268
|
let children: string[];
|
|
239
269
|
try {
|
|
240
|
-
children = await fs.readdir(path.join(wsDir, this.kbDirName,
|
|
270
|
+
children = await fs.readdir(path.join(wsDir, this.kbDirName, PLUGINS_DIR));
|
|
241
271
|
} catch {
|
|
242
|
-
return null; // no
|
|
272
|
+
return null; // no Plugins/ root yet — nothing can collide
|
|
243
273
|
}
|
|
244
274
|
const lower = name.toLowerCase();
|
|
245
275
|
return children.find((c) => c.toLowerCase() === lower) ?? null;
|
|
246
276
|
}
|
|
247
277
|
|
|
278
|
+
/** An existing PLUGIN FOLDER whose derived manifest name equals `name`'s, or null. */
|
|
279
|
+
private async manifestNameTwin(name: string): Promise<string | null> {
|
|
280
|
+
const wsId = await this.readyWorkspaceId();
|
|
281
|
+
const wsDir = await this.workspaceService.getWorkspacePath(wsId);
|
|
282
|
+
let children: Array<{ name: string; isDirectory(): boolean }>;
|
|
283
|
+
try {
|
|
284
|
+
children = await fs.readdir(path.join(wsDir, this.kbDirName, PLUGINS_DIR), {
|
|
285
|
+
withFileTypes: true,
|
|
286
|
+
});
|
|
287
|
+
} catch {
|
|
288
|
+
return null; // no Plugins/ root yet — nothing can collide
|
|
289
|
+
}
|
|
290
|
+
const slug = pluginManifestName(name);
|
|
291
|
+
// Only what actually publishes a manifest claims a slug: a DIRECTORY
|
|
292
|
+
// that is not dot-prefixed (a parked delete — invisible to every
|
|
293
|
+
// scanner). Personal folders count — they publish a plugin.json like
|
|
294
|
+
// any plugin (though the reservation above means a named plugin can
|
|
295
|
+
// never reach this check with a personal slug). A loose file at the
|
|
296
|
+
// root (`Plugins/slack.tool`) is not a plugin and must not 409 a
|
|
297
|
+
// legitimate "Slack Tool".
|
|
298
|
+
return (
|
|
299
|
+
children.find(
|
|
300
|
+
(c) => c.isDirectory() && !c.name.startsWith('.') && pluginManifestName(c.name) === slug,
|
|
301
|
+
)?.name ?? null
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
248
305
|
private async provision(user: AuthUser, folder: string, accessMd: string): Promise<void> {
|
|
249
306
|
const wsId = await this.readyWorkspaceId();
|
|
250
|
-
const
|
|
307
|
+
const folderPath = `${this.kbDirName}/${PLUGINS_DIR}/${folder}`;
|
|
308
|
+
const wsRelPath = `${folderPath}/access.md`;
|
|
251
309
|
try {
|
|
252
310
|
// Exclusive create — the fs is the arbiter of a same-name race, not
|
|
253
|
-
// the (stale-able) existence check above.
|
|
311
|
+
// the (stale-able) existence check above. `access.md` stays the marker
|
|
312
|
+
// that a folder is real (every scanner keys on it), so it is still the
|
|
313
|
+
// file the race is decided on.
|
|
254
314
|
await this.workspaceService.writeFile(wsId, wsRelPath, accessMd, { failIfExists: true });
|
|
255
315
|
} catch (err) {
|
|
256
316
|
if ((err as { status?: number }).status === 409) {
|
|
257
|
-
throw new
|
|
317
|
+
throw new PluginProvisionError(`A plugin named "${folder}" already exists.`, 409);
|
|
258
318
|
}
|
|
259
319
|
throw err;
|
|
260
320
|
}
|
|
261
321
|
try {
|
|
322
|
+
// The manifest is what makes the folder a PLUGIN to anything outside
|
|
323
|
+
// this app, so it lands in the same commit as the access rules — and
|
|
324
|
+
// INSIDE the rollback scope: a manifest write that fails must clean up
|
|
325
|
+
// the access.md it would otherwise strand as a half-made plugin.
|
|
326
|
+
await this.workspaceService.writeFile(
|
|
327
|
+
wsId,
|
|
328
|
+
`${folderPath}/${PLUGIN_MANIFEST_FILE}`,
|
|
329
|
+
renderPluginManifest(folder),
|
|
330
|
+
);
|
|
262
331
|
// Inline, not enqueued: the gate reads rules at HEAD, so the folder is
|
|
263
332
|
// only real once this commit lands. `runPendingCommit` is the same
|
|
264
333
|
// commit+push (with pull-rebase recovery) the queue worker runs.
|
|
@@ -267,19 +336,20 @@ export class GroupProvisionService {
|
|
|
267
336
|
// rule this endpoint exists to carve through. The endpoint has already
|
|
268
337
|
// authorized the write (any signed-in user, unused name, exclusive
|
|
269
338
|
// create), so the per-user gate is skipped for exactly this commit.
|
|
270
|
-
await this.commits.runPendingCommit(wsId, DEFAULT_BRANCH,
|
|
339
|
+
await this.commits.runPendingCommit(wsId, DEFAULT_BRANCH, folderPath, user, {
|
|
271
340
|
systemAuthorized: true,
|
|
272
341
|
});
|
|
273
342
|
} catch (err) {
|
|
274
343
|
// The commit did not land: roll the seeded file back off the disk,
|
|
275
|
-
// best-effort, so a retry doesn't find a half-made
|
|
344
|
+
// best-effort, so a retry doesn't find a half-made plugin and report
|
|
276
345
|
// "already exists" for something that never got committed. Only OUR
|
|
277
346
|
// file and — when that leaves it empty — the folder; never recursive,
|
|
278
347
|
// so a concurrent writer's bytes can't be collateral.
|
|
279
348
|
try {
|
|
280
349
|
const wsDir = await this.workspaceService.getWorkspacePath(wsId);
|
|
281
|
-
const folderDir = path.join(wsDir, this.kbDirName,
|
|
350
|
+
const folderDir = path.join(wsDir, this.kbDirName, PLUGINS_DIR, folder);
|
|
282
351
|
await fs.rm(path.join(folderDir, 'access.md'), { force: true });
|
|
352
|
+
await fs.rm(path.join(folderDir, PLUGIN_MANIFEST_FILE), { force: true });
|
|
283
353
|
await fs.rmdir(folderDir).catch(() => {});
|
|
284
354
|
} catch {
|
|
285
355
|
/* leave it for the next attempt's wx conflict — better than masking the real error */
|
|
@@ -299,13 +369,13 @@ export class GroupProvisionService {
|
|
|
299
369
|
}
|
|
300
370
|
|
|
301
371
|
/**
|
|
302
|
-
* A named
|
|
303
|
-
* — anyone may see the
|
|
372
|
+
* A named plugin's access.md: discoverable file (frontmatter `read: everyone`
|
|
373
|
+
* — anyone may see the plugin listed and ask to join), creator-run folder
|
|
304
374
|
* (body read/write/owner name the creator). The `read: []` placeholder makes
|
|
305
375
|
* the body parse as rules from the first byte, so every later splice targets
|
|
306
376
|
* the body rather than the frontmatter.
|
|
307
377
|
*/
|
|
308
|
-
export function
|
|
378
|
+
export function pluginAccessMd(creator: { name: string; email: string }): string {
|
|
309
379
|
return withCreatorGrants('---\nread:\n - everyone\n---\nread: []\n', creator);
|
|
310
380
|
}
|
|
311
381
|
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* tools those skills need. A
|
|
4
|
-
* boundary, one place, derived from the path exactly the way `
|
|
2
|
+
* Plugins — the folders under `Plugins/` that carry a team's skills and the
|
|
3
|
+
* tools those skills need. A plugin is a FOLDER, never a role: one access
|
|
4
|
+
* boundary, one place, derived from the path exactly the way `pluginOfPath`
|
|
5
5
|
* derives it.
|
|
6
6
|
*
|
|
7
|
-
* A
|
|
8
|
-
* the provisioning endpoint seeds. A bare directory under `
|
|
9
|
-
*
|
|
7
|
+
* A plugin EXISTS exactly when its folder carries an `access.md` — the file
|
|
8
|
+
* the provisioning endpoint seeds. A bare directory under `Plugins/` is not a
|
|
9
|
+
* plugin (git cannot record an empty folder, so deleted plugins leave ghost
|
|
10
10
|
* directories on live checkouts).
|
|
11
11
|
*
|
|
12
12
|
* Enumeration then follows the KB's read model with NO special cases: an
|
|
13
|
-
* existing
|
|
13
|
+
* existing plugin appears for a caller exactly when the access resolution says
|
|
14
14
|
* something about it —
|
|
15
15
|
*
|
|
16
16
|
* - MEMBER: the caller can read the folder itself (`canRead`).
|
|
@@ -18,11 +18,11 @@
|
|
|
18
18
|
* admin-rescued).
|
|
19
19
|
* - DISCOVERABLE: the caller can read the `access.md` FILE — which, in the
|
|
20
20
|
* new body-governed format, is granted by the file's own
|
|
21
|
-
* `read: everyone` frontmatter (see `accessMdSelfEntries`). The
|
|
21
|
+
* `read: everyone` frontmatter (see `accessMdSelfEntries`). The plugin
|
|
22
22
|
* shows locked, and the caller may ask to join.
|
|
23
23
|
*
|
|
24
|
-
* A
|
|
25
|
-
* name, counts and principals never leave the backend. Making a
|
|
24
|
+
* A plugin where all three verdicts are false is ABSENT from the response —
|
|
25
|
+
* name, counts and principals never leave the backend. Making a plugin secret
|
|
26
26
|
* is therefore an ordinary access edit: drop the `everyone` grant from its
|
|
27
27
|
* access.md frontmatter.
|
|
28
28
|
*
|
|
@@ -43,32 +43,32 @@ export interface ResolvedReaders extends ResolvedPrincipals {
|
|
|
43
43
|
restricted: boolean;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/** One
|
|
47
|
-
export interface
|
|
48
|
-
/**
|
|
46
|
+
/** One plugin as `GET /api/plugins` reports it, resolved for ONE caller. */
|
|
47
|
+
export interface PluginSummary {
|
|
48
|
+
/** Plugin folder name, e.g. `GTM`. */
|
|
49
49
|
name: string;
|
|
50
|
-
/** Repo-relative constituent folders, e.g. `['
|
|
50
|
+
/** Repo-relative constituent folders, e.g. `['Plugins/GTM']`. */
|
|
51
51
|
folders: string[];
|
|
52
52
|
/**
|
|
53
53
|
* Per-caller: the caller can read the FOLDER (membership). Every returned
|
|
54
|
-
*
|
|
55
|
-
*
|
|
54
|
+
* plugin has at least one of `canRead` / `canWrite` / discoverability; a
|
|
55
|
+
* plugin with none is not returned at all.
|
|
56
56
|
*/
|
|
57
57
|
canRead: boolean;
|
|
58
58
|
/**
|
|
59
59
|
* Per-caller; true ⇒ may manage access. Resolved as write on the folder's
|
|
60
60
|
* `access.md`, which admin-rescues — so a platform Admin locked OUT of
|
|
61
|
-
* reading still sees the
|
|
61
|
+
* reading still sees the plugin and gets the self-service way back in.
|
|
62
62
|
*/
|
|
63
63
|
canWrite: boolean;
|
|
64
64
|
/**
|
|
65
65
|
* Per-caller: the caller holds the `owner` verb on the FOLDER — resolved
|
|
66
|
-
* from the `owner:` lists alone, no admin rescue. Deleting the
|
|
66
|
+
* from the `owner:` lists alone, no admin rescue. Deleting the plugin is the
|
|
67
67
|
* owner's verb (the DELETE route enforces the same verdict); managers who
|
|
68
68
|
* merely write the access.md do not get it.
|
|
69
69
|
*/
|
|
70
70
|
isOwner: boolean;
|
|
71
|
-
/** Caller-INDEPENDENT total (the
|
|
71
|
+
/** Caller-INDEPENDENT total (the plugin's whole content, not the caller's slice). */
|
|
72
72
|
skillCount: number;
|
|
73
73
|
toolCount: number;
|
|
74
74
|
/** For display: "Run by …" (fallback chain: owners → writers → 'the workspace admins'). */
|
|
@@ -76,7 +76,7 @@ export interface GroupSummary {
|
|
|
76
76
|
writers: ResolvedPrincipals;
|
|
77
77
|
readers: ResolvedReaders;
|
|
78
78
|
/**
|
|
79
|
-
* The caller has an OPEN join change request for this
|
|
79
|
+
* The caller has an OPEN join change request for this plugin (their
|
|
80
80
|
* deterministic join branch has an open CR). Always false for a member.
|
|
81
81
|
*/
|
|
82
82
|
hasRequested: boolean;
|
|
@@ -85,11 +85,11 @@ export interface GroupSummary {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
|
-
* The caller-INDEPENDENT slice of a
|
|
89
|
-
* caches. The per-caller verdicts (`canRead`/`canWrite`, and whether the
|
|
88
|
+
* The caller-INDEPENDENT slice of a plugin — what `catalog()` computes once and
|
|
89
|
+
* caches. The per-caller verdicts (`canRead`/`canWrite`, and whether the plugin
|
|
90
90
|
* appears at all) are resolved per request in the route.
|
|
91
91
|
*/
|
|
92
|
-
export interface
|
|
92
|
+
export interface PluginCatalogEntry {
|
|
93
93
|
name: string;
|
|
94
94
|
folders: string[];
|
|
95
95
|
skillCount: number;
|
|
@@ -99,9 +99,9 @@ export interface GroupCatalogEntry {
|
|
|
99
99
|
readers: ResolvedReaders;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
export interface
|
|
102
|
+
export interface IPluginIndexService {
|
|
103
103
|
/** Caller-independent catalog part (folders, counts, principals) — cached 60s. */
|
|
104
|
-
catalog(): Promise<
|
|
105
|
-
/** Drop the cached catalog (call after a default-branch change under a
|
|
104
|
+
catalog(): Promise<PluginCatalogEntry[]>;
|
|
105
|
+
/** Drop the cached catalog (call after a default-branch change under a plugin root). */
|
|
106
106
|
invalidate(): void;
|
|
107
107
|
}
|