@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.
Files changed (230) hide show
  1. package/LICENSE +202 -202
  2. package/THIRD-PARTY-NOTICES.md +428 -454
  3. package/dist/core/core-ports.d.ts +1 -1
  4. package/dist/core/create-core-server.d.ts.map +1 -1
  5. package/dist/core/create-core-server.js +51 -7
  6. package/dist/core/create-core-server.js.map +1 -1
  7. package/dist/core/create-core-services.d.ts +5 -3
  8. package/dist/core/create-core-services.d.ts.map +1 -1
  9. package/dist/core/create-core-services.js +30 -18
  10. package/dist/core/create-core-services.js.map +1 -1
  11. package/dist/modules/access/access-control.interface.d.ts +8 -7
  12. package/dist/modules/access/access-control.interface.d.ts.map +1 -1
  13. package/dist/modules/access/access-control.service.d.ts +1 -1
  14. package/dist/modules/access/access-control.service.d.ts.map +1 -1
  15. package/dist/modules/access/access-control.service.js +6 -6
  16. package/dist/modules/access/access-control.service.js.map +1 -1
  17. package/dist/modules/access/access-declarations.d.ts +5 -5
  18. package/dist/modules/access/access-declarations.js +3 -3
  19. package/dist/modules/access/access-mutation.service.d.ts +3 -3
  20. package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
  21. package/dist/modules/access/access-mutation.service.js +3 -3
  22. package/dist/modules/access/access-mutation.service.js.map +1 -1
  23. package/dist/modules/access/access-splice.js +4 -4
  24. package/dist/modules/access/access-splice.js.map +1 -1
  25. package/dist/modules/access/access.routes.js +19 -19
  26. package/dist/modules/access/access.routes.js.map +1 -1
  27. package/dist/modules/access/creator-access.d.ts +2 -2
  28. package/dist/modules/access/creator-access.js +5 -5
  29. package/dist/modules/access/creator-access.js.map +1 -1
  30. package/dist/modules/access/roles-admin.service.d.ts +18 -4
  31. package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
  32. package/dist/modules/access/roles-admin.service.js +14 -4
  33. package/dist/modules/access/roles-admin.service.js.map +1 -1
  34. package/dist/modules/code-mode/code-mode-names.d.ts +5 -13
  35. package/dist/modules/code-mode/code-mode-names.d.ts.map +1 -1
  36. package/dist/modules/code-mode/code-mode-names.js +5 -27
  37. package/dist/modules/code-mode/code-mode-names.js.map +1 -1
  38. package/dist/modules/code-mode/code-mode.tool.d.ts.map +1 -1
  39. package/dist/modules/code-mode/code-mode.tool.js +29 -7
  40. package/dist/modules/code-mode/code-mode.tool.js.map +1 -1
  41. package/dist/modules/mcp/mcp.service.d.ts +11 -52
  42. package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
  43. package/dist/modules/mcp/mcp.service.js +33 -395
  44. package/dist/modules/mcp/mcp.service.js.map +1 -1
  45. package/dist/modules/plugins/index.d.ts +7 -0
  46. package/dist/modules/plugins/index.d.ts.map +1 -0
  47. package/dist/modules/plugins/index.js +6 -0
  48. package/dist/modules/plugins/index.js.map +1 -0
  49. package/dist/modules/plugins/join-proposals.d.ts +53 -0
  50. package/dist/modules/plugins/join-proposals.d.ts.map +1 -0
  51. package/dist/modules/plugins/join-proposals.js +67 -0
  52. package/dist/modules/plugins/join-proposals.js.map +1 -0
  53. package/dist/modules/plugins/join-requests.service.d.ts +81 -0
  54. package/dist/modules/plugins/join-requests.service.d.ts.map +1 -0
  55. package/dist/modules/plugins/join-requests.service.js +135 -0
  56. package/dist/modules/plugins/join-requests.service.js.map +1 -0
  57. package/dist/modules/plugins/plugin-provision.service.d.ts +134 -0
  58. package/dist/modules/plugins/plugin-provision.service.d.ts.map +1 -0
  59. package/dist/modules/plugins/plugin-provision.service.js +344 -0
  60. package/dist/modules/plugins/plugin-provision.service.js.map +1 -0
  61. package/dist/modules/plugins/plugins.contract.d.ts +106 -0
  62. package/dist/modules/plugins/plugins.contract.d.ts.map +1 -0
  63. package/dist/modules/plugins/plugins.contract.js +36 -0
  64. package/dist/modules/plugins/plugins.contract.js.map +1 -0
  65. package/dist/modules/plugins/plugins.routes.d.ts +42 -0
  66. package/dist/modules/plugins/plugins.routes.d.ts.map +1 -0
  67. package/dist/modules/plugins/plugins.routes.js +379 -0
  68. package/dist/modules/plugins/plugins.routes.js.map +1 -0
  69. package/dist/modules/plugins/plugins.service.d.ts +60 -0
  70. package/dist/modules/plugins/plugins.service.d.ts.map +1 -0
  71. package/dist/modules/plugins/plugins.service.js +172 -0
  72. package/dist/modules/plugins/plugins.service.js.map +1 -0
  73. package/dist/modules/skills/pending-skills.service.d.ts +2 -2
  74. package/dist/modules/skills/pending-skills.service.js +7 -7
  75. package/dist/modules/skills/pending-skills.service.js.map +1 -1
  76. package/dist/modules/skills/skills.contract.d.ts +4 -4
  77. package/dist/modules/skills/skills.contract.d.ts.map +1 -1
  78. package/dist/modules/skills/skills.contract.js +1 -1
  79. package/dist/modules/skills/skills.service.js +5 -5
  80. package/dist/modules/skills/skills.service.js.map +1 -1
  81. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts +65 -0
  82. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -0
  83. package/dist/modules/tool-manuals/mcp-json-discovery.js +276 -0
  84. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -0
  85. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts +92 -0
  86. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -0
  87. package/dist/modules/tool-manuals/mcp-server-edit.service.js +328 -0
  88. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -0
  89. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +38 -12
  90. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  91. package/dist/modules/tool-manuals/tool-manuals.contract.js +1 -1
  92. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts +13 -2
  93. package/dist/modules/tool-manuals/tool-manuals.routes.d.ts.map +1 -1
  94. package/dist/modules/tool-manuals/tool-manuals.routes.js +233 -2
  95. package/dist/modules/tool-manuals/tool-manuals.routes.js.map +1 -1
  96. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  97. package/dist/modules/tool-manuals/tool-manuals.service.js +74 -37
  98. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  99. package/dist/modules/tool-manuals/tool-manuals.tools.js +6 -3
  100. package/dist/modules/tool-manuals/tool-manuals.tools.js.map +1 -1
  101. package/dist/modules/workflow/git/git.service.js +2 -2
  102. package/dist/modules/workflow/git/git.service.js.map +1 -1
  103. package/dist/modules/workspace/kb-seed.service.d.ts +2 -2
  104. package/dist/modules/workspace/kb-seed.service.d.ts.map +1 -1
  105. package/dist/modules/workspace/kb-seed.service.js +43 -10
  106. package/dist/modules/workspace/kb-seed.service.js.map +1 -1
  107. package/dist/modules/workspace/plugins-migration.d.ts +50 -0
  108. package/dist/modules/workspace/plugins-migration.d.ts.map +1 -0
  109. package/dist/modules/workspace/plugins-migration.js +379 -0
  110. package/dist/modules/workspace/plugins-migration.js.map +1 -0
  111. package/dist/modules/workspace/workspace.routes.js +3 -3
  112. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  113. package/dist/modules/workspace/workspace.service.d.ts +26 -0
  114. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  115. package/dist/modules/workspace/workspace.service.js +83 -12
  116. package/dist/modules/workspace/workspace.service.js.map +1 -1
  117. package/dist/shared/kb-layout.test.js +3 -3
  118. package/dist/shared/kb-layout.test.js.map +1 -1
  119. package/dist/shared/utcp-namespace.d.ts +6 -27
  120. package/dist/shared/utcp-namespace.d.ts.map +1 -1
  121. package/dist/shared/utcp-namespace.js +6 -63
  122. package/dist/shared/utcp-namespace.js.map +1 -1
  123. package/dist/shared/variable-refs.d.ts +42 -0
  124. package/dist/shared/variable-refs.d.ts.map +1 -0
  125. package/dist/shared/variable-refs.js +60 -0
  126. package/dist/shared/variable-refs.js.map +1 -0
  127. package/kb-template/.bevelignore +1 -1
  128. package/kb-template/AGENTS.md +88 -35
  129. package/kb-template/KnowledgeBase/How to get started.md +10 -10
  130. package/kb-template/access.md +36 -36
  131. package/migrations/meta/0000_snapshot.json +1479 -1479
  132. package/package.json +5 -4
  133. package/src/assets.ts +25 -25
  134. package/src/core/core-ports.ts +106 -106
  135. package/src/core/create-core-server.ts +55 -9
  136. package/src/core/create-core-services.ts +40 -20
  137. package/src/index.ts +69 -69
  138. package/src/modules/access/__tests__/access-control.atref-batch.test.ts +98 -98
  139. package/src/modules/access/__tests__/access-declarations.test.ts +28 -28
  140. package/src/modules/access/__tests__/access-md-format.test.ts +18 -18
  141. package/src/modules/access/__tests__/access-mutation.service.test.ts +5 -5
  142. package/src/modules/access/__tests__/access-splice.test.ts +2 -2
  143. package/src/modules/access/__tests__/access.routes.overrides.test.ts +16 -16
  144. package/src/modules/access/__tests__/grant-sources.test.ts +12 -12
  145. package/src/modules/access/__tests__/roles-admin.service.test.ts +13 -1
  146. package/src/modules/access/access-control.interface.ts +8 -7
  147. package/src/modules/access/access-control.service.ts +7 -7
  148. package/src/modules/access/access-declarations.ts +5 -5
  149. package/src/modules/access/access-mutation.service.ts +3 -3
  150. package/src/modules/access/access-splice.ts +4 -4
  151. package/src/modules/access/access.routes.ts +20 -20
  152. package/src/modules/access/creator-access.ts +5 -5
  153. package/src/modules/access/roles-admin.service.ts +13 -2
  154. package/src/modules/admin/admin-access.routes.ts +29 -29
  155. package/src/modules/auth/__tests__/auth.routes.test.ts +91 -91
  156. package/src/modules/auth/__tests__/rate-limit.test.ts +36 -36
  157. package/src/modules/auth/rate-limit.ts +45 -45
  158. package/src/modules/code-mode/__tests__/code-mode.tool.test.ts +67 -0
  159. package/src/modules/code-mode/code-mode-names.ts +10 -36
  160. package/src/modules/code-mode/code-mode.tool.ts +27 -7
  161. package/src/modules/database/connection.ts +15 -15
  162. package/src/modules/database/schema.ts +11 -11
  163. package/src/modules/diff/__tests__/diff.routes.rejectPathsLocked.test.ts +150 -150
  164. package/src/modules/mcp/mcp.service.ts +57 -435
  165. package/src/modules/{groups → plugins}/__tests__/join-proposals.test.ts +1 -1
  166. package/src/modules/{groups → plugins}/__tests__/join-requests.service.test.ts +7 -7
  167. package/src/modules/{groups/__tests__/group-index.service.test.ts → plugins/__tests__/plugin-index.service.test.ts} +41 -41
  168. package/src/modules/plugins/__tests__/plugin-provision.service.test.ts +312 -0
  169. package/src/modules/{groups/__tests__/groups.routes.test.ts → plugins/__tests__/plugins.routes.test.ts} +100 -100
  170. package/src/modules/plugins/index.ts +17 -0
  171. package/src/modules/{groups → plugins}/join-proposals.ts +2 -2
  172. package/src/modules/{groups → plugins}/join-requests.service.ts +8 -8
  173. package/src/modules/{groups/group-provision.service.ts → plugins/plugin-provision.service.ts} +139 -69
  174. package/src/modules/{groups/groups.contract.ts → plugins/plugins.contract.ts} +26 -26
  175. package/src/modules/{groups/groups.routes.ts → plugins/plugins.routes.ts} +102 -102
  176. package/src/modules/{groups/groups.service.ts → plugins/plugins.service.ts} +43 -43
  177. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +143 -143
  178. package/src/modules/skills/__tests__/pending-skills.service.test.ts +14 -14
  179. package/src/modules/skills/__tests__/skills.service.test.ts +13 -13
  180. package/src/modules/skills/pending-skills.service.ts +7 -7
  181. package/src/modules/skills/skills.contract.ts +4 -4
  182. package/src/modules/skills/skills.service.ts +5 -5
  183. package/src/modules/tool-auth/llm-usage-meter.ts +19 -19
  184. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +198 -0
  185. package/src/modules/tool-manuals/__tests__/mcp-server-edit.service.test.ts +346 -0
  186. package/src/modules/tool-manuals/__tests__/tool-manuals.archive.route.test.ts +101 -0
  187. package/src/modules/tool-manuals/__tests__/tool-manuals.detail.route.test.ts +3 -3
  188. package/src/modules/tool-manuals/__tests__/tool-manuals.mcp-oauth.test.ts +2 -2
  189. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +27 -27
  190. package/src/modules/tool-manuals/__tests__/tool-manuals.tools.test.ts +3 -3
  191. package/src/modules/tool-manuals/mcp-json-discovery.ts +328 -0
  192. package/src/modules/tool-manuals/mcp-server-edit.service.ts +434 -0
  193. package/src/modules/tool-manuals/tool-manuals.contract.ts +35 -12
  194. package/src/modules/tool-manuals/tool-manuals.routes.ts +222 -1
  195. package/src/modules/tool-manuals/tool-manuals.service.ts +82 -42
  196. package/src/modules/tool-manuals/tool-manuals.tools.ts +6 -3
  197. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +1 -1
  198. package/src/modules/workflow/git/__tests__/branch-name.test.ts +3 -3
  199. package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +3 -3
  200. package/src/modules/workflow/git/__tests__/git.service.commitFile.test.ts +13 -8
  201. package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +1 -1
  202. package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +1 -1
  203. package/src/modules/workflow/git/git.service.ts +2 -2
  204. package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +1 -1
  205. package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +1 -1
  206. package/src/modules/workflow/workflow-hooks.ts +101 -101
  207. package/src/modules/workspace/__tests__/kb-seed.service.test.ts +81 -13
  208. package/src/modules/workspace/__tests__/plugins-migration.test.ts +427 -0
  209. package/src/modules/workspace/__tests__/session-ontology.gate.test.ts +237 -237
  210. package/src/modules/workspace/__tests__/workspace.routes.create-grant.test.ts +236 -236
  211. package/src/modules/workspace/__tests__/workspace.routes.delete.test.ts +179 -179
  212. package/src/modules/workspace/__tests__/workspace.routes.download.test.ts +320 -320
  213. package/src/modules/workspace/__tests__/workspace.routes.read-gate.test.ts +337 -337
  214. package/src/modules/workspace/__tests__/workspace.service.test.ts +116 -0
  215. package/src/modules/workspace/bevel-ignore.ts +66 -66
  216. package/src/modules/workspace/kb-seed.service.ts +38 -9
  217. package/src/modules/workspace/plugins-migration.ts +479 -0
  218. package/src/modules/workspace/session-sink.ts +25 -25
  219. package/src/modules/workspace/workspace.routes.ts +3 -3
  220. package/src/modules/workspace/workspace.service.ts +85 -14
  221. package/src/modules/workspace/workspace.tools.ts +922 -922
  222. package/src/shared/__tests__/join-request.test.ts +13 -13
  223. package/src/shared/__tests__/kb-layout.plugin.test.ts +45 -0
  224. package/src/shared/kb-layout.test.ts +3 -3
  225. package/src/shared/utcp-namespace.ts +10 -68
  226. package/src/shared/variable-refs.ts +64 -0
  227. package/src/modules/groups/__tests__/group-provision.service.test.ts +0 -247
  228. package/src/modules/groups/index.ts +0 -17
  229. package/src/shared/__tests__/kb-layout.group.test.ts +0 -45
  230. /package/kb-template/{Groups → Plugins}/.gitkeep +0 -0
@@ -1,13 +1,13 @@
1
1
  /**
2
- * Group provisioning — the ONE privileged door for bringing a `Groups/<name>/`
3
- * folder into existence, and (via {@link GroupProvisionService.deleteGroup})
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 group is the single operation the access model cannot govern
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 `Groups/`, and the gate had to re-derive "is this that
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 group: discoverable by design. The access.md's own frontmatter
25
- * reads `everyone` — anyone may open the FILE, see the group listed, and
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
- GROUPS_DIR,
40
- PERSONAL_GROUP_PREFIX,
41
- isPersonalGroupFolder,
42
- personalGroupFolderName,
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 ProvisionedGroup {
63
- /** The folder name under `Groups/` (not the full path). */
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 GroupProvisionError extends Error {
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 = 'GroupProvisionError';
79
+ this.name = 'PluginProvisionError';
77
80
  }
78
81
  }
79
82
 
80
- export class GroupProvisionService {
83
+ export class PluginProvisionService {
81
84
  /**
82
- * Serialises creations by NORMALIZED (lowercased) folder name. The wx write
83
- * arbitrates same-path races, but on a case-sensitive filesystem `GTM` and
84
- * `gtm` are different paths — without this lock two concurrent requests
85
- * could both pass the collision check and both land, breaking the
86
- * case-insensitive uniqueness the check promises.
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 `Groups/<name>/` for `user`. Throws `GroupProvisionError` 422 on a
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 createGroup(user: AuthUser, rawName: string): Promise<ProvisionedGroup> {
108
+ async createPlugin(user: AuthUser, rawName: string): Promise<ProvisionedPlugin> {
105
109
  const name = rawName.trim();
106
- if (!name) throw new GroupProvisionError('A group needs a name.', 422);
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 GroupProvisionError(
112
- 'A group name can\'t contain / or \\ or control characters, or start with a dot.',
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
- if (name.toLowerCase().startsWith(PERSONAL_GROUP_PREFIX)) {
117
- // Reserved: the personal-folder namespace. A group squatting there
118
- // would collide with somebody's future personal folder.
119
- throw new GroupProvisionError(
120
- `Group names starting with "${PERSONAL_GROUP_PREFIX}" are reserved.`,
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
- return this.creations.run(`group:${name.toLowerCase()}`, async () => {
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 GroupProvisionError(`A group named "${existing}" already exists.`, 409);
143
+ throw new PluginProvisionError(`A plugin named "${existing}" already exists.`, 409);
128
144
  }
129
- await this.provision(user, name, groupAccessMd(user));
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 ensurePersonalGroup(user: AuthUser): Promise<ProvisionedGroup> {
139
- const folder = personalGroupFolderName(user.id);
140
- return this.creations.run(`group:${folder.toLowerCase()}`, async () => {
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 GroupProvisionError && err.status === 409) {
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 `Groups/<name>/` — the whole folder, its skills and tools
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 `createGroup` leaves
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
- * group exactly as it was — never half-gone on disk while origin still
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 per-name lock creations use, so a delete can
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 deleteGroup(user: AuthUser, rawName: string): Promise<void> {
207
+ async deletePlugin(user: AuthUser, rawName: string): Promise<void> {
178
208
  const name = rawName.trim();
179
- if (!name) throw new GroupProvisionError('A group needs a name.', 422);
180
- if (isPersonalGroupFolder(name)) {
181
- // Personal folders are not groups (the catalog never lists them), and
182
- // nobody deletes somebody's private shelf through the group door.
183
- throw new GroupProvisionError('Unknown group', 404);
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(`group:${name.toLowerCase()}`, async () => {
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 group is gone (or was never there).
189
- if (existing !== name) throw new GroupProvisionError('Unknown group', 404);
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 groupsDir = path.join(wsDir, this.kbDirName, GROUPS_DIR);
194
- const folderDir = path.join(groupsDir, name);
195
- // Dot-prefixed ⇒ invisible to the group scanner and the collision
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(groupsDir, `.${name}.deleting`);
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 group.
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}/${GROUPS_DIR}/${name}`,
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 group that exists at origin but not here.
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, GROUPS_DIR));
270
+ children = await fs.readdir(path.join(wsDir, this.kbDirName, PLUGINS_DIR));
241
271
  } catch {
242
- return null; // no Groups/ root yet — nothing can collide
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 wsRelPath = `${this.kbDirName}/${GROUPS_DIR}/${folder}/access.md`;
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 GroupProvisionError(`A group named "${folder}" already exists.`, 409);
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, wsRelPath, user, {
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 group and report
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, GROUPS_DIR, folder);
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 group's access.md: discoverable file (frontmatter `read: everyone`
303
- * — anyone may see the group listed and ask to join), creator-run folder
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 groupAccessMd(creator: { name: string; email: string }): string {
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
- * Groups — the folders under `Groups/` that carry a team's skills and the
3
- * tools those skills need. A group is a FOLDER, never a role: one access
4
- * boundary, one place, derived from the path exactly the way `groupOfPath`
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 group EXISTS exactly when its folder carries an `access.md` — the file
8
- * the provisioning endpoint seeds. A bare directory under `Groups/` is not a
9
- * group (git cannot record an empty folder, so deleted groups leave ghost
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 group appears for a caller exactly when the access resolution says
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 group
21
+ * `read: everyone` frontmatter (see `accessMdSelfEntries`). The plugin
22
22
  * shows locked, and the caller may ask to join.
23
23
  *
24
- * A group where all three verdicts are false is ABSENT from the response —
25
- * name, counts and principals never leave the backend. Making a group secret
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 group as `GET /api/groups` reports it, resolved for ONE caller. */
47
- export interface GroupSummary {
48
- /** Group folder name, e.g. `GTM`. */
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. `['Groups/GTM']`. */
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
- * group has at least one of `canRead` / `canWrite` / discoverability; a
55
- * group with none is not returned at all.
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 group and gets the self-service way back in.
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 group is 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 group's whole content, not the caller's slice). */
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 group (their
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 group — what `catalog()` computes once and
89
- * caches. The per-caller verdicts (`canRead`/`canWrite`, and whether the group
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 GroupCatalogEntry {
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 IGroupIndexService {
102
+ export interface IPluginIndexService {
103
103
  /** Caller-independent catalog part (folders, counts, principals) — cached 60s. */
104
- catalog(): Promise<GroupCatalogEntry[]>;
105
- /** Drop the cached catalog (call after a default-branch change under a group root). */
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
  }