@k2b/cloud 0.6.0 → 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 (349) hide show
  1. package/README.md +2 -2
  2. package/package.json +22 -11
  3. package/scripts/README.md +10 -0
  4. package/scripts/browser-performance.ts +14 -0
  5. package/scripts/build-pdf-renderer.ts +39 -0
  6. package/scripts/build.ts +20 -35
  7. package/scripts/preload.ts +16 -12
  8. package/scripts/sync-recovery-smoke.ts +22 -6
  9. package/src/_internal/app-presentation.ts +14 -2
  10. package/src/_internal/build-metadata.ts +8 -1
  11. package/src/_internal/capabilities.ts +131 -7
  12. package/src/_internal/capability-streams.ts +81 -0
  13. package/src/_internal/define-app.ts +80 -37
  14. package/src/_internal/heartbeat.ts +4 -0
  15. package/src/_internal/help-catalog.ts +27 -172
  16. package/src/_internal/help.ts +13 -76
  17. package/src/_internal/page-responses.ts +2 -0
  18. package/src/_internal/postgres-application-name.ts +30 -0
  19. package/src/_internal/process-identity.ts +15 -0
  20. package/src/_internal/process-sync.ts +1 -1
  21. package/src/_internal/readiness.ts +4 -1
  22. package/src/_internal/registry-validation.ts +29 -25
  23. package/src/_internal/registry.ts +2 -53
  24. package/src/_internal/runtime-context.ts +5 -16
  25. package/src/_internal/server-timing.ts +13 -0
  26. package/src/_internal/status-preserving-ssr.ts +18 -6
  27. package/src/_internal/web-vitals-asset.ts +10 -0
  28. package/src/access/PermissionEditor.tsx +18 -151
  29. package/src/access/PrincipalPicker.tsx +110 -0
  30. package/src/access/ResourceApiKeys.tsx +1 -1
  31. package/src/access/ui.ts +1 -0
  32. package/src/ai/admin.ts +14 -13
  33. package/src/ai/assistant-models.ts +12 -1
  34. package/src/ai/audio-format.ts +34 -0
  35. package/src/ai/audio-tool.ts +90 -0
  36. package/src/ai/browser-code-contracts.ts +201 -0
  37. package/src/ai/browser.ts +4 -0
  38. package/src/ai/capabilities.ts +55 -36
  39. package/src/ai/capability-execution.ts +47 -12
  40. package/src/ai/chat/blocks.tsx +229 -90
  41. package/src/ai/chat/builtin-tools.tsx +2 -4
  42. package/src/ai/chat/capability-result.ts +15 -0
  43. package/src/ai/chat/capability-table.tsx +2 -10
  44. package/src/ai/chat/composer-adapter.ts +83 -3
  45. package/src/ai/chat/message-actions.tsx +8 -7
  46. package/src/ai/chat/message-utils.ts +25 -1
  47. package/src/ai/chat/messages.ts +2 -0
  48. package/src/ai/chat/presentation.tsx +31 -47
  49. package/src/ai/chat/primitives.tsx +43 -1
  50. package/src/ai/chat/tool-disclosure.tsx +21 -2
  51. package/src/ai/chat/tool-groups.ts +58 -0
  52. package/src/ai/chat-quotas.ts +25 -0
  53. package/src/ai/chat-task-contracts.ts +14 -10
  54. package/src/ai/chat-tasks.ts +297 -43
  55. package/src/ai/client/controller.ts +165 -39
  56. package/src/ai/client/file-source.ts +12 -6
  57. package/src/ai/client/live-connection.ts +1 -0
  58. package/src/ai/client/projection.ts +29 -11
  59. package/src/ai/client/transport.ts +6 -0
  60. package/src/ai/code-capability-routes.ts +105 -0
  61. package/src/ai/code-capability-transport.ts +39 -0
  62. package/src/ai/code-execution.ts +53 -0
  63. package/src/ai/code-mode-skill.ts +118 -0
  64. package/src/ai/code-runtime-tools.ts +179 -0
  65. package/src/ai/code-source-contracts.ts +270 -0
  66. package/src/ai/code-source-tools.ts +188 -0
  67. package/src/ai/compaction.ts +27 -6
  68. package/src/ai/data-analysis-skill.ts +10 -0
  69. package/src/ai/default-tools.ts +99 -3
  70. package/src/ai/dictation-runtime.ts +171 -0
  71. package/src/ai/dictations.ts +188 -0
  72. package/src/ai/draft-content.ts +49 -0
  73. package/src/ai/enrich.ts +6 -0
  74. package/src/ai/executor.ts +189 -33
  75. package/src/ai/fetch-file-tool.ts +2 -2
  76. package/src/ai/file-content-version.ts +27 -0
  77. package/src/ai/file-context.ts +2 -1
  78. package/src/ai/file-media-type.ts +12 -0
  79. package/src/ai/file-reference-contracts.ts +15 -0
  80. package/src/ai/file-tools.ts +8 -1
  81. package/src/ai/files-store.ts +212 -71
  82. package/src/ai/fixtures/vision-encrypted.pdf +0 -0
  83. package/src/ai/grids-skill.ts +42 -10
  84. package/src/ai/http.ts +26 -8
  85. package/src/ai/index.ts +26 -3
  86. package/src/ai/inference-calls.ts +197 -0
  87. package/src/ai/live-events.ts +5 -1
  88. package/src/ai/live.ts +1 -1
  89. package/src/ai/markdown-pdf-tool.ts +2 -2
  90. package/src/ai/memories.ts +30 -27
  91. package/src/ai/memory-learning-runs.ts +1 -1
  92. package/src/ai/memory-learning.ts +64 -32
  93. package/src/ai/memory-tool.ts +1 -5
  94. package/src/ai/memory-workflow-evidence.ts +9 -4
  95. package/src/ai/message-queue.ts +178 -0
  96. package/src/ai/migrate.ts +189 -11
  97. package/src/ai/model-pricing.ts +43 -0
  98. package/src/ai/pdf-render-worker.ts +75 -0
  99. package/src/ai/pdf-render.fixture.ts +28 -0
  100. package/src/ai/pdf-render.ts +77 -0
  101. package/src/ai/projects-routes.ts +18 -43
  102. package/src/ai/projects.ts +33 -14
  103. package/src/ai/protocol.ts +3 -0
  104. package/src/ai/provider.ts +1 -8
  105. package/src/ai/quota-provider.ts +192 -0
  106. package/src/ai/quota-report.ts +152 -0
  107. package/src/ai/quotas-migrate.ts +45 -0
  108. package/src/ai/quotas.ts +168 -0
  109. package/src/ai/routes.ts +241 -11
  110. package/src/ai/run-timeout.ts +12 -0
  111. package/src/ai/runtime-tools.ts +7 -5
  112. package/src/ai/runtime.ts +74 -17
  113. package/src/ai/settings.ts +36 -8
  114. package/src/ai/short-id.ts +4 -5
  115. package/src/ai/skill-catalog.ts +0 -10
  116. package/src/ai/skill-search.ts +58 -0
  117. package/src/ai/skill-seeds.ts +114 -20
  118. package/src/ai/skill-tool.ts +21 -5
  119. package/src/ai/skills-routes.ts +49 -4
  120. package/src/ai/skills.ts +358 -163
  121. package/src/ai/store.ts +416 -201
  122. package/src/ai/stream.ts +5 -0
  123. package/src/ai/structured-runs.ts +4 -3
  124. package/src/ai/structured.ts +12 -1
  125. package/src/ai/system-prompt.ts +14 -2
  126. package/src/ai/task-contracts.ts +84 -0
  127. package/src/ai/task-execution.ts +92 -0
  128. package/src/ai/timeline.ts +2 -5
  129. package/src/ai/todo-contracts.ts +32 -0
  130. package/src/ai/todo-tool.ts +19 -0
  131. package/src/ai/tool-audit.ts +30 -40
  132. package/src/ai/tools.ts +6 -0
  133. package/src/ai/transcription.ts +202 -0
  134. package/src/ai/turn-timing.ts +114 -0
  135. package/src/ai/types.ts +58 -10
  136. package/src/ai/ui.tsx +3 -0
  137. package/src/ai/usage.ts +118 -118
  138. package/src/ai/vision-tool.ts +60 -10
  139. package/src/api/admin-ai-projects.ts +2 -2
  140. package/src/api/admin-ai-quotas.ts +80 -0
  141. package/src/api/admin-ai-skills.ts +48 -5
  142. package/src/api/admin-ai-usage.ts +2 -2
  143. package/src/api/admin-core-settings.ts +25 -0
  144. package/src/api/admin-identity.ts +91 -51
  145. package/src/api/admin-linux-identities.ts +10 -4
  146. package/src/api/admin-rail.ts +58 -0
  147. package/src/api/announcements.ts +14 -0
  148. package/src/api/app-approval.ts +2 -2
  149. package/src/api/auth.ts +1 -1
  150. package/src/api/capabilities.ts +324 -43
  151. package/src/api/capability-streams.ts +262 -0
  152. package/src/api/help.ts +8 -44
  153. package/src/api/index.ts +8 -2
  154. package/src/api/mcp.ts +45 -34
  155. package/src/api/me-web-vitals.ts +35 -0
  156. package/src/api/me.ts +2 -0
  157. package/src/api/search/schemas.ts +26 -15
  158. package/src/api/search.bench.ts +1 -1
  159. package/src/api/search.ts +54 -17
  160. package/src/browser/CloudResourceSearch.tsx +640 -702
  161. package/src/browser/app-approval-vault.ts +19 -7
  162. package/src/browser/command-bridge.ts +109 -0
  163. package/src/browser/command-shortcuts.ts +76 -0
  164. package/src/browser/commands.ts +67 -0
  165. package/src/browser/navigation-search.ts +48 -0
  166. package/src/browser/notifications.ts +1 -1
  167. package/src/browser/resource-picker.tsx +5 -18
  168. package/src/browser/resource-search-dialog.ts +9 -0
  169. package/src/browser/resource-search-input.ts +46 -0
  170. package/src/browser/resource-search-messages.ts +76 -40
  171. package/src/browser/resource-search.ts +7 -0
  172. package/src/browser/search-bridge.ts +102 -0
  173. package/src/browser/search-commands.ts +59 -0
  174. package/src/browser/search.ts +13 -0
  175. package/src/browser/spotlight-position.ts +208 -0
  176. package/src/browser/testing.ts +6 -0
  177. package/src/browser/web-vitals.ts +25 -0
  178. package/src/capabilities/claims.ts +179 -0
  179. package/src/capabilities/client.ts +34 -0
  180. package/src/capabilities/command-link.ts +16 -0
  181. package/src/capabilities/executions.ts +298 -0
  182. package/src/capabilities/migrate.ts +81 -0
  183. package/src/capabilities/server.ts +116 -35
  184. package/src/capabilities/stream-body.ts +16 -0
  185. package/src/capabilities/streams.ts +28 -0
  186. package/src/cli/admin/ai-quotas.ts +161 -0
  187. package/src/cli/admin/ai-skills.ts +64 -0
  188. package/src/cli/admin/ai-usage.ts +53 -30
  189. package/src/cli/admin/app-credentials.ts +77 -0
  190. package/src/cli/admin/data.ts +17 -0
  191. package/src/cli/admin/index.ts +10 -1
  192. package/src/cli/admin/instance.ts +2 -2
  193. package/src/cli/admin/linux.ts +4 -2
  194. package/src/cli/admin/sync.ts +1 -1
  195. package/src/cli/capabilities.ts +42 -0
  196. package/src/cli/capability-streams.ts +25 -0
  197. package/src/cli/index.ts +3 -2
  198. package/src/config/define-env.ts +97 -0
  199. package/src/config/env.ts +147 -51
  200. package/src/config/index.ts +3 -1
  201. package/src/contracts/app.ts +23 -3
  202. package/src/contracts/capabilities.ts +75 -5
  203. package/src/contracts/capability-streams.ts +46 -0
  204. package/src/contracts/commands.ts +49 -0
  205. package/src/contracts/index.ts +5 -2
  206. package/src/contracts/rail-admin.ts +38 -0
  207. package/src/contracts/rail-preferences.ts +16 -0
  208. package/src/contracts/registry.ts +5 -27
  209. package/src/contracts/settings-types.ts +1 -1
  210. package/src/contracts/shared.ts +18 -5
  211. package/src/contracts/web-vitals.ts +27 -0
  212. package/src/desktop/index.ts +1 -0
  213. package/src/desktop/solid.tsx +1 -1
  214. package/src/index.ts +8 -6
  215. package/src/server/index.ts +3 -0
  216. package/src/server/middleware/auth.ts +3 -2
  217. package/src/server/middleware/route-template.ts +9 -3
  218. package/src/server/middleware/runtime.ts +2 -1
  219. package/src/server/middleware/settings.ts +17 -11
  220. package/src/server/ratelimit.ts +1 -1
  221. package/src/server/services/access-revision.ts +10 -0
  222. package/src/server/services/access.ts +3 -1
  223. package/src/server/services/index.ts +1 -0
  224. package/src/services/account-category-policy.ts +9 -0
  225. package/src/services/account-lifecycle/audit.ts +1 -1
  226. package/src/services/accounts/app.ts +12 -2
  227. package/src/services/accounts/email-write.ts +23 -0
  228. package/src/services/accounts/groups.ts +15 -4
  229. package/src/services/accounts/identities.ts +249 -0
  230. package/src/services/accounts/identity-reconciliation.ts +87 -0
  231. package/src/services/accounts/ipa-data.ts +2 -2
  232. package/src/services/accounts/local-groups.ts +2 -2
  233. package/src/services/accounts/notification-sender.ts +12 -2
  234. package/src/services/accounts/posix.ts +45 -29
  235. package/src/services/accounts/users.ts +2 -2
  236. package/src/services/announcements/index.ts +69 -8
  237. package/src/services/app-approval.ts +11 -10
  238. package/src/services/audit/index.ts +1 -1
  239. package/src/services/cache-fill.ts +35 -0
  240. package/src/services/help/index.ts +109 -0
  241. package/src/services/help/maintenance.ts +25 -0
  242. package/src/services/help/store.ts +180 -0
  243. package/src/services/help/types.ts +46 -0
  244. package/src/services/identity/invocation-actor.ts +2 -2
  245. package/src/services/identity/key-config.ts +8 -5
  246. package/src/services/identity/key-ring.ts +1 -1
  247. package/src/services/identity/runtime-config.ts +6 -5
  248. package/src/services/index.ts +15 -0
  249. package/src/services/ipa/posix.ts +1 -1
  250. package/src/services/ipa/sync.ts +1 -1
  251. package/src/services/ipa/users.ts +268 -221
  252. package/src/services/legal-consent.ts +1 -1
  253. package/src/services/logging/index.ts +15 -13
  254. package/src/services/logging/trace.ts +2 -1
  255. package/src/services/logging/web-vitals.ts +81 -0
  256. package/src/services/mandates/index.ts +21 -4
  257. package/src/services/mandates/policy.ts +165 -2
  258. package/src/services/notifications/batches.ts +125 -57
  259. package/src/services/notifications/browser.ts +12 -0
  260. package/src/services/notifications/index.ts +0 -2
  261. package/src/services/notifications/platform.ts +0 -4
  262. package/src/services/oauth-tokens.ts +3 -3
  263. package/src/services/pdf/gotenberg.ts +131 -72
  264. package/src/services/pdf/index.ts +11 -8
  265. package/src/services/providers/local/users.ts +25 -16
  266. package/src/services/public-http.ts +101 -0
  267. package/src/services/rail-shortcuts.ts +95 -0
  268. package/src/services/rail-snapshot.ts +53 -0
  269. package/src/services/request-cache-redis.ts +29 -0
  270. package/src/services/session/index.ts +15 -2
  271. package/src/services/session/user.ts +62 -37
  272. package/src/services/settings/app.ts +5 -1
  273. package/src/services/settings/core-settings.ts +26 -35
  274. package/src/services/settings/defaults.ts +5 -0
  275. package/src/services/settings/index.ts +3 -3
  276. package/src/services/settings/store.ts +58 -84
  277. package/src/services/webauthn.ts +1 -1
  278. package/src/shared/ai-costs.ts +42 -0
  279. package/src/shared/ai-platform-prompt.ts +1 -1
  280. package/src/shared/ai-quotas.ts +136 -0
  281. package/src/shared/ai-usage.ts +6 -16
  282. package/src/shared/app-presentation.ts +21 -15
  283. package/src/shared/capability-messages.ts +14 -0
  284. package/src/shared/help.ts +6 -42
  285. package/src/shared/index.ts +3 -0
  286. package/src/shared/markdown/extensions/info-blocks.ts +1 -1
  287. package/src/ssr/AdminLayout.tsx +7 -5
  288. package/src/ssr/AdminSidebar.tsx +30 -16
  289. package/src/ssr/AppLaunchpad.island.tsx +33 -136
  290. package/src/ssr/AppLaunchpadPanel.tsx +181 -0
  291. package/src/ssr/BrowserPushRegistration.island.tsx +12 -0
  292. package/src/ssr/GlobalSearchDialog.tsx +167 -20
  293. package/src/ssr/GlobalSearchTrigger.island.tsx +33 -22
  294. package/src/ssr/Layout.tsx +17 -6
  295. package/src/ssr/LayoutBreadcrumbs.island.tsx +3 -5
  296. package/src/ssr/LayoutFooter.tsx +1 -1
  297. package/src/ssr/LayoutHeader.tsx +46 -14
  298. package/src/ssr/LayoutHelp.tsx +22 -23
  299. package/src/ssr/{HotkeysHelpRail.island.tsx → LayoutHelpTrigger.island.tsx} +18 -12
  300. package/src/ssr/LayoutRail.tsx +17 -9
  301. package/src/ssr/MinimalLayout.tsx +1 -1
  302. package/src/ssr/MobileNavigation.tsx +71 -0
  303. package/src/ssr/MobileProfileActions.tsx +49 -0
  304. package/src/ssr/ProfilePreferences.island.tsx +3 -36
  305. package/src/ssr/RailApps.island.tsx +2 -2
  306. package/src/ssr/RailEditor.tsx +17 -2
  307. package/src/ssr/WorkspaceNavigation.island.tsx +7 -0
  308. package/src/ssr/WorkspaceNavigationProvider.tsx +16 -0
  309. package/src/ssr/admin-navigation.ts +11 -1
  310. package/src/ssr/help.ts +29 -0
  311. package/src/ssr/islands/SearchBar.tsx +67 -0
  312. package/src/ssr/islands/index.ts +3 -1
  313. package/src/ssr/layout-context.ts +2 -2
  314. package/src/ssr/layout-help-search.ts +15 -0
  315. package/src/ssr/mobile-menu-history.ts +70 -0
  316. package/src/ssr/platform-messages.ts +16 -8
  317. package/src/ssr/profile-actions.ts +42 -0
  318. package/src/ssr/rail-context.ts +8 -4
  319. package/src/ssr/rail-messages.ts +2 -0
  320. package/src/ssr/rail-navigation.ts +3 -3
  321. package/src/ssr/workspace-navigation.ts +75 -0
  322. package/src/styles/effects.css +6 -6
  323. package/src/styles/global.css +54 -0
  324. package/src/styles/input.css +43 -41
  325. package/src/styles/resource-search.css +505 -0
  326. package/src/styles/utilities-feedback.css +13 -0
  327. package/src/styles/utilities-layout.css +1 -5
  328. package/src/styles/utilities-navigation.css +3 -39
  329. package/src/types/ambient.d.ts +5 -0
  330. package/src/workflows/ai/index.ts +2 -2
  331. package/src/workflows/ai/runtime.ts +19 -90
  332. package/src/workflows/ai/store.ts +2 -0
  333. package/src/workflows/ai/types.ts +6 -82
  334. package/src/workflows/ai-actions.ts +16 -13
  335. package/src/workflows/definition.ts +10 -5
  336. package/src/workflows/store/actions.ts +44 -20
  337. package/src/workflows/store/index.ts +4 -2
  338. package/src/workflows/store/runs.ts +36 -0
  339. package/src/workflows/store/worker-pool.ts +55 -0
  340. package/src/workflows/store/worker-runtime.ts +90 -0
  341. package/src/ai/kit-skill.ts +0 -22
  342. package/src/ai/usage-migrate.ts +0 -81
  343. package/src/browser/notification-target.ts +0 -27
  344. package/src/contracts/notification-live.ts +0 -66
  345. package/src/server/services/freeipa/test-certificates.ts +0 -44
  346. package/src/services/notifications/live.ts +0 -50
  347. package/src/services/session/test-fixture.ts +0 -18
  348. package/src/ssr/BrowserNotifications.island.tsx +0 -135
  349. package/src/ssr/islands/SearchBar.island.tsx +0 -80
package/src/ai/skills.ts CHANGED
@@ -1,4 +1,6 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { type SQL, type SQLQuery, sql } from "bun";
3
+ import { AuthenticatedPrincipalSchema } from "../contracts/shared";
2
4
  import type { AccessSubject } from "../server";
3
5
  import {
4
6
  buildAccessPrincipalCondition,
@@ -8,8 +10,10 @@ import {
8
10
  type PermissionLevel,
9
11
  type Principal,
10
12
  } from "../server/services/access";
13
+ import { accessRevision } from "../server/services/access-revision";
11
14
  import { toPgUuidArray } from "../services/postgres";
12
15
  import { mountAiSkillFilePath } from "./file-mount";
16
+ import { aiProjects } from "./projects";
13
17
  import { AI_SHORT_ID_PATTERN, withAiShortIdForDb } from "./short-id";
14
18
  import {
15
19
  type AiSkillExtraFrontmatter,
@@ -21,6 +25,8 @@ import {
21
25
  validateAiSkillName,
22
26
  validateAiSkillReferences,
23
27
  } from "./skill-format";
28
+ import { aiSkillSearchSql, withAiSkillSearch } from "./skill-search";
29
+ import { getBuiltinAiSkillTemplates } from "./skill-seeds";
24
30
  import type { AiSkillFileToolContent, AiSkillFileToolStat } from "./types";
25
31
 
26
32
  export type AiSkillPermission = Exclude<PermissionLevel, "none">;
@@ -55,7 +61,22 @@ export type AiSkillAccess = {
55
61
  createdAt: string;
56
62
  };
57
63
 
64
+ export type AiSkillTemplate = {
65
+ key: string;
66
+ version: number;
67
+ name: string;
68
+ description: string;
69
+ instructions: string;
70
+ extraFrontmatter?: AiSkillExtraFrontmatter;
71
+ references?: readonly AiSkillReferenceInput[];
72
+ };
73
+
58
74
  export type AiSkillAdminListItem = {
75
+ revision: number;
76
+ templateId: string | null;
77
+ templateVersion: number | null;
78
+ currentTemplateVersion: number | null;
79
+ templateStatus: "current" | "modified" | "update_available" | null;
59
80
  id: string;
60
81
  shortId: string;
61
82
  name: string;
@@ -86,6 +107,9 @@ type SkillRow = {
86
107
  id: string;
87
108
  short_id: string;
88
109
  managed_key: string | null;
110
+ template_id: string | null;
111
+ template_version: number | null;
112
+ template_hash: string | null;
89
113
  name: string;
90
114
  description: string;
91
115
  instructions: string;
@@ -106,13 +130,14 @@ type SkillAccessRow = {
106
130
  display_name: string | null;
107
131
  };
108
132
 
109
- type SkillSummaryRow = SkillRow & {
133
+ type SkillSummaryRow = Pick<SkillRow, "id" | "short_id" | "name" | "description" | "revision" | "created_at" | "updated_at"> & {
110
134
  permission: AiSkillPermission;
111
135
  reference_count: number;
112
136
  enabled: boolean;
113
137
  };
114
138
 
115
139
  type AdminSkillRow = SkillRow & {
140
+ content_references: AiSkillReference[] | string;
116
141
  reference_count: number;
117
142
  access_count: number;
118
143
  admin_count: number;
@@ -162,15 +187,17 @@ const principalForSubject = (subject: AccessSubject): Principal =>
162
187
  : { type: "service_account", serviceAccountId: subject.serviceAccountId };
163
188
 
164
189
  const accessMatch = (subject: AccessSubject | null): SQLQuery =>
165
- buildAccessPrincipalCondition({
166
- subject,
167
- columns: {
168
- userId: sql`access.user_id`,
169
- groupId: sql`access.group_id`,
170
- serviceAccountId: sql`access.service_account_id`,
171
- authenticatedOnly: sql`access.authenticated_only`,
172
- },
173
- });
190
+ subject === null
191
+ ? sql`FALSE`
192
+ : buildAccessPrincipalCondition({
193
+ subject,
194
+ columns: {
195
+ userId: sql`access.user_id`,
196
+ groupId: sql`access.group_id`,
197
+ serviceAccountId: sql`access.service_account_id`,
198
+ authenticatedOnly: sql`access.authenticated_only`,
199
+ },
200
+ });
174
201
 
175
202
  const getRow = async (skillId: string, db: SQL = sql): Promise<SkillRow | null> =>
176
203
  (await db<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid`)[0] ?? null;
@@ -181,17 +208,24 @@ const getRowByShortId = async (shortId: string, db: SQL = sql): Promise<SkillRow
181
208
  const getRowByName = async (name: string, db: SQL = sql): Promise<SkillRow | null> =>
182
209
  (await db<SkillRow[]>`SELECT * FROM ai.skills WHERE name = ${name}`)[0] ?? null;
183
210
 
211
+ // Project links grant read only. Their creator's later permissions are irrelevant.
212
+ const effectivePermission = (skillId: SQLQuery, subject: AccessSubject | null) => sql`
213
+ SELECT permission FROM (
214
+ SELECT access.permission FROM ai.skill_access link JOIN auth.access access ON access.id=link.access_id
215
+ WHERE link.skill_id=${skillId} AND ${accessMatch(subject)}
216
+ UNION ALL
217
+ SELECT 'read'::auth.permission_level FROM ai.project_skills link
218
+ JOIN ai.project_access project_access ON project_access.project_id=link.project_id
219
+ JOIN auth.access access ON access.id=project_access.access_id
220
+ WHERE link.skill_id=${skillId} AND access.permission <> 'none' AND ${accessMatch(subject)}
221
+ ) grants ORDER BY CASE permission WHEN 'admin' THEN 3 WHEN 'write' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC LIMIT 1
222
+ `;
223
+
184
224
  const permissionFor = async (skillId: string, subject: AccessSubject | null, db: SQL = sql): Promise<AiSkillPermission | "none"> => {
185
- const match = accessMatch(subject);
186
- const rows = await db<{ permission: AiSkillPermission }[]>`
187
- SELECT access.permission
188
- FROM ai.skill_access skill_access
189
- JOIN auth.access access ON access.id = skill_access.access_id
190
- WHERE skill_access.skill_id = ${skillId}::uuid AND ${match}
191
- ORDER BY CASE access.permission WHEN 'admin' THEN 3 WHEN 'write' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC
192
- LIMIT 1
193
- `;
194
- return rows[0]?.permission ?? "none";
225
+ const [row] = await db<
226
+ { permission: AiSkillPermission | null }[]
227
+ >`SELECT (${effectivePermission(sql`${skillId}::uuid`, subject)}) AS permission`;
228
+ return row?.permission ?? "none";
195
229
  };
196
230
 
197
231
  const enabledFor = async (skillId: string, subject: AccessSubject | null, db: SQL = sql): Promise<boolean> => {
@@ -280,6 +314,7 @@ const createSkillAccess = async (
280
314
  input: { principal: Principal; permission: AiSkillPermission },
281
315
  db: SQL,
282
316
  ): Promise<AiSkillAccess> => {
317
+ AuthenticatedPrincipalSchema.parse(input.principal);
283
318
  const created = await createAccess(input, db);
284
319
  if (!created.ok) throw new Error(created.error.message);
285
320
  let shortId = "";
@@ -300,6 +335,10 @@ const createSkillAccess = async (
300
335
  return entry;
301
336
  };
302
337
 
338
+ const checkAccessRevision = async (skillId: string, expected: string | undefined, db: SQL) => {
339
+ if (expected !== undefined && accessRevision(await listSkillAccess(skillId, db)) !== expected) throw new AiSkillRevisionConflictError();
340
+ };
341
+
303
342
  const updateSkillAccess = async (skillId: string, accessId: string, permission: AiSkillPermission, db: SQL): Promise<boolean> => {
304
343
  const [bound] = await db<{ id: string; permission: AiSkillPermission }[]>`
305
344
  SELECT access.id, access.permission
@@ -361,26 +400,92 @@ const validatedFields = (input: {
361
400
  }
362
401
  };
363
402
 
364
- const sameReferences = (left: readonly AiSkillReference[], right: readonly AiSkillReference[]): boolean =>
365
- left.length === right.length &&
366
- left.every((reference, index) => reference.path === right[index]?.path && reference.content === right[index]?.content);
403
+ // JSON object key order and reference insertion order are not content changes.
404
+ const canonicalJson = (value: unknown): string => {
405
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
406
+ if (value !== null && typeof value === "object") {
407
+ return `{${Object.entries(value)
408
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
409
+ .map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`)
410
+ .join(",")}}`;
411
+ }
412
+ return JSON.stringify(value);
413
+ };
414
+
415
+ const contentHash = (fields: ReturnType<typeof validatedFields>): string =>
416
+ createHash("sha256")
417
+ .update(
418
+ canonicalJson({
419
+ ...fields,
420
+ references: [...fields.references].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
421
+ }),
422
+ )
423
+ .digest("hex");
424
+
425
+ const rowContentHash = async (row: SkillRow, db: SQL = sql): Promise<string> =>
426
+ contentHash({
427
+ name: row.name,
428
+ description: row.description,
429
+ instructions: row.instructions,
430
+ extraFrontmatter: jsonObject(row.extra_frontmatter),
431
+ references: await listReferences(row.id, db),
432
+ });
433
+
434
+ const validateTemplate = (input: AiSkillTemplate) => {
435
+ if (!input.key.trim() || !Number.isSafeInteger(input.version) || input.version < 1 || input.version > 2147483647)
436
+ throw new AiSkillInputError("A template needs a stable ID and a positive integer version.");
437
+ const fields = validatedFields(input);
438
+ return { fields, hash: contentHash(fields) };
439
+ };
440
+
441
+ const replaceTemplate = async (row: SkillRow, template: AiSkillTemplate, db: SQL): Promise<void> => {
442
+ const { fields, hash } = validateTemplate(template);
443
+ await db`UPDATE ai.skills SET name = ${fields.name}, description = ${fields.description},
444
+ instructions = ${fields.instructions}, extra_frontmatter = (${JSON.stringify(fields.extraFrontmatter)}::text)::jsonb,
445
+ template_id = ${template.key}, template_version = ${template.version}, template_hash = ${hash},
446
+ revision = revision + 1, updated_at = now() WHERE id = ${row.id}::uuid`;
447
+ await db`DELETE FROM ai.skill_references WHERE skill_id = ${row.id}::uuid`;
448
+ for (const ref of fields.references)
449
+ await db`INSERT INTO ai.skill_references (skill_id, path, content)
450
+ VALUES (${row.id}::uuid, ${ref.path}, ${ref.content})`;
451
+ };
367
452
 
368
453
  const skillSearchPattern = (search?: string): string | null => {
369
454
  const value = search?.trim().toLowerCase();
370
455
  return value ? `%${value.replace(/[\\%_]/g, (char) => `\\${char}`)}%` : null;
371
456
  };
372
457
 
373
- const toAdminSkill = (row: AdminSkillRow): AiSkillAdminListItem => ({
374
- id: row.id,
375
- shortId: row.short_id,
376
- name: row.name,
377
- description: row.description,
378
- referenceCount: Number(row.reference_count),
379
- accessCount: Number(row.access_count),
380
- adminCount: Number(row.admin_count),
381
- createdAt: iso(row.created_at),
382
- updatedAt: iso(row.updated_at),
383
- });
458
+ const toAdminSkill = (row: AdminSkillRow): AiSkillAdminListItem => {
459
+ const template = getBuiltinAiSkillTemplates().find((entry) => entry.key === row.template_id);
460
+ return {
461
+ revision: Number(row.revision),
462
+ templateId: row.template_id,
463
+ templateVersion: row.template_version,
464
+ currentTemplateVersion: template?.version ?? null,
465
+ templateStatus: !row.template_id
466
+ ? null
467
+ : template && template.version > Number(row.template_version)
468
+ ? "update_available"
469
+ : contentHash({
470
+ name: row.name,
471
+ description: row.description,
472
+ instructions: row.instructions,
473
+ extraFrontmatter: jsonObject(row.extra_frontmatter),
474
+ references: snapshotFiles(row.content_references),
475
+ }) === row.template_hash
476
+ ? "current"
477
+ : "modified",
478
+ id: row.id,
479
+ shortId: row.short_id,
480
+ name: row.name,
481
+ description: row.description,
482
+ referenceCount: Number(row.reference_count),
483
+ accessCount: Number(row.access_count),
484
+ adminCount: Number(row.admin_count),
485
+ createdAt: iso(row.created_at),
486
+ updatedAt: iso(row.updated_at),
487
+ };
488
+ };
384
489
 
385
490
  const skillSnapshotFiles = (skill: AiSkill): { path: string; content: string }[] => [
386
491
  {
@@ -390,90 +495,111 @@ const skillSnapshotFiles = (skill: AiSkill): { path: string; content: string }[]
390
495
  ...skill.references.map((reference) => ({ path: `${skill.name}/${reference.path}`, content: reference.content })),
391
496
  ];
392
497
 
393
- export const aiSkills = {
394
- async seedOnce(input: {
395
- key: string;
396
- name: string;
397
- description: string;
398
- instructions: string;
399
- extraFrontmatter?: AiSkillExtraFrontmatter;
400
- references?: readonly AiSkillReferenceInput[];
401
- }): Promise<void> {
402
- const key = input.key.trim();
403
- if (!key) throw new AiSkillInputError("Skill seed key is required.");
404
- const fields = validatedFields(input);
405
- await sql.begin(async (tx) => {
406
- const [claimed] = await tx<{ key: string }[]>`
407
- INSERT INTO ai.skill_seeds (key) VALUES (${key})
408
- ON CONFLICT (key) DO NOTHING
409
- RETURNING key
410
- `;
411
- if (!claimed) return;
498
+ type SkillListOptions = { query?: string; enabledOnly?: boolean; limit?: number };
499
+ async function listSkillSummaries(
500
+ subject: AccessSubject | null,
501
+ options: SkillListOptions & {
502
+ project?: { id: string; linked: boolean };
503
+ manageOnly?: boolean;
504
+ offset?: number;
505
+ } = {},
506
+ ): Promise<AiSkillSummary[]> {
507
+ const userId = subject?.type === "user" ? subject.userId : null;
508
+ const query = (options.query ?? "").trim().slice(0, 200);
509
+ const limit = Math.max(1, Math.min(201, options.limit ?? 200));
510
+ const rows = await withAiSkillSearch(query, async (bm25) => {
511
+ const search = aiSkillSearchSql(query, bm25);
512
+ return sql<SkillSummaryRow[]>`
513
+ SELECT skill.id, skill.short_id, skill.name, skill.description, skill.revision, skill.created_at, skill.updated_at,
514
+ access.permission,
515
+ count(DISTINCT reference.path)::int AS reference_count,
516
+ (disabled.user_id IS NULL) AS enabled
517
+ FROM ai.skills skill
518
+ JOIN LATERAL (${effectivePermission(sql`skill.id`, subject)}) access ON true
519
+ LEFT JOIN ai.skill_references reference ON reference.skill_id = skill.id
520
+ LEFT JOIN ai.skill_user_disabled disabled
521
+ ON disabled.skill_id = skill.id AND disabled.user_id = ${userId}::uuid
522
+ WHERE access.permission <> 'none'
523
+ AND (${!options.manageOnly} OR access.permission='admin')
524
+ AND ${options.project ? sql`EXISTS(SELECT 1 FROM ai.project_skills link WHERE link.skill_id=skill.id AND link.project_id=${options.project.id}::uuid) = ${options.project.linked}` : sql`true`}
525
+ AND (${!options.enabledOnly} OR disabled.user_id IS NULL) AND ${search.matches}
526
+ GROUP BY skill.id, disabled.user_id, access.permission
527
+ ORDER BY ${search.rank} DESC, ${search.textRank} DESC, skill.name, skill.id
528
+ LIMIT ${limit} OFFSET ${options.offset ?? 0}
529
+ `;
530
+ });
531
+ return rows.map((row) => ({
532
+ id: row.id,
533
+ shortId: row.short_id,
534
+ name: row.name,
535
+ description: row.description,
536
+ permission: row.permission,
537
+ revision: Number(row.revision),
538
+ referenceCount: Number(row.reference_count),
539
+ enabled: row.enabled,
540
+ createdAt: iso(row.created_at),
541
+ updatedAt: iso(row.updated_at),
542
+ }));
543
+ }
412
544
 
413
- let [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE managed_key = ${key} FOR UPDATE`;
414
- if (!row) {
415
- const [conflict] = await tx<{ id: string }[]>`SELECT id FROM ai.skills WHERE name = ${fields.name}`;
416
- if (conflict) return;
417
- const rows = await withAiShortIdForDb(
418
- tx,
419
- "idx_ai_skills_short_id",
420
- (attempt, shortId) => attempt<SkillRow[]>`
421
- INSERT INTO ai.skills (short_id, name, description, instructions, extra_frontmatter)
422
- VALUES (
423
- ${shortId}, ${fields.name}, ${fields.description}, ${fields.instructions},
424
- (${JSON.stringify(fields.extraFrontmatter)}::text)::jsonb
425
- )
426
- RETURNING *
427
- `,
428
- );
429
- row = rows[0]!;
430
- } else {
431
- const references = await listReferences(row.id, tx);
432
- const changed =
433
- row.name !== fields.name ||
434
- row.description !== fields.description ||
435
- row.instructions !== fields.instructions ||
436
- JSON.stringify(jsonObject(row.extra_frontmatter)) !== JSON.stringify(fields.extraFrontmatter) ||
437
- !sameReferences(references, fields.references);
438
- if (changed) {
439
- [row] = await tx<SkillRow[]>`
440
- UPDATE ai.skills
441
- SET name = ${fields.name}, description = ${fields.description}, instructions = ${fields.instructions},
442
- extra_frontmatter = (${JSON.stringify(fields.extraFrontmatter)}::text)::jsonb,
443
- managed_key = NULL, revision = revision + 1, updated_at = now()
444
- WHERE id = ${row.id}::uuid
445
- RETURNING *
446
- `;
447
- } else {
448
- [row] = await tx<SkillRow[]>`
449
- UPDATE ai.skills SET managed_key = NULL WHERE id = ${row.id}::uuid RETURNING *
450
- `;
451
- }
452
- }
545
+ async function linkedSkillProjects(skillId: string, subject: AccessSubject | null) {
546
+ const links = await sql<
547
+ { projectId: string }[]
548
+ >`SELECT project_id AS "projectId" FROM ai.project_skills WHERE skill_id=${skillId}::uuid ORDER BY project_id`;
549
+ const result: Array<{ projectId: string; shortId: string | null; name: string | null }> = [];
550
+ for (const link of links) {
551
+ const project = await aiProjects.get(link.projectId, subject);
552
+ result.push({ ...link, shortId: project?.shortId ?? null, name: project?.name ?? null });
553
+ }
554
+ return result;
555
+ }
453
556
 
454
- const currentReferences = await listReferences(row!.id, tx);
455
- if (!sameReferences(currentReferences, fields.references)) {
456
- await tx`DELETE FROM ai.skill_references WHERE skill_id = ${row!.id}::uuid`;
457
- for (const reference of fields.references) {
458
- await tx`
459
- INSERT INTO ai.skill_references (skill_id, path, content)
460
- VALUES (${row!.id}::uuid, ${reference.path}, ${reference.content})
461
- `;
557
+ export const aiSkills = {
558
+ async seedOnce(template: AiSkillTemplate): Promise<void> {
559
+ const { fields, hash } = validateTemplate(template);
560
+ await sql.begin(async (tx) => {
561
+ const [claimed] = await tx`INSERT INTO ai.skill_seeds (key, catalog_version)
562
+ VALUES (${template.key}, ${template.version}) ON CONFLICT (key) DO NOTHING RETURNING key`;
563
+ // Seed lock always precedes the Skill lock, including administrator adoption/reset.
564
+ const [seed] = await tx<{ skill_id: string | null; catalog_version: number }[]>`
565
+ SELECT skill_id, catalog_version FROM ai.skill_seeds WHERE key = ${template.key} FOR UPDATE`;
566
+ if (!seed || seed.catalog_version > template.version) return;
567
+ await tx`UPDATE ai.skill_seeds SET catalog_version = ${template.version} WHERE key = ${template.key}`;
568
+ if (seed.skill_id) {
569
+ const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${seed.skill_id}::uuid FOR UPDATE`;
570
+ if (!row || row.template_id !== template.key || Number(row.template_version) >= template.version) return;
571
+ if ((await rowContentHash(row, tx)) !== row.template_hash) return;
572
+ // A user may have claimed the new name. Keep the existing Skill rather than fail startup.
573
+ const [collision] = await tx`SELECT id FROM ai.skills WHERE name = ${fields.name} AND id <> ${row.id}::uuid`;
574
+ if (!collision) {
575
+ try {
576
+ await tx.savepoint((attempt) => replaceTemplate(row, template, attempt));
577
+ } catch (error) {
578
+ // A concurrent normal create may claim the name after our read.
579
+ if (!(typeof error === "object" && error !== null && "constraint" in error && error.constraint === "idx_ai_skills_name"))
580
+ throw error;
581
+ }
462
582
  }
583
+ return;
463
584
  }
464
-
465
- const [grant] = await tx<{ id: string; permission: AiSkillPermission }[]>`
466
- SELECT access.id, access.permission
467
- FROM ai.skill_access skill_access
468
- JOIN auth.access access ON access.id = skill_access.access_id
469
- WHERE skill_access.skill_id = ${row!.id}::uuid AND access.authenticated_only = true
470
- LIMIT 1
471
- `;
472
- if (!grant) {
473
- await createSkillAccess(row!.id, { principal: { type: "authenticated" }, permission: "read" }, tx);
474
- } else if (grant.permission !== "read") {
475
- await tx`UPDATE auth.access SET permission = 'read' WHERE id = ${grant.id}::uuid`;
476
- }
585
+ // Old seed markers and name collisions never authorize adoption or replacement.
586
+ if (!claimed) return;
587
+ const [collision] = await tx`SELECT id FROM ai.skills WHERE name = ${fields.name}`;
588
+ if (collision) return;
589
+ const [row] = await withAiShortIdForDb(
590
+ tx,
591
+ "idx_ai_skills_short_id",
592
+ (attempt, shortId) => attempt<SkillRow[]>`
593
+ INSERT INTO ai.skills (short_id, name, description, instructions, extra_frontmatter, template_id, template_version, template_hash)
594
+ VALUES (${shortId}, ${fields.name}, ${fields.description}, ${fields.instructions},
595
+ (${JSON.stringify(fields.extraFrontmatter)}::text)::jsonb, ${template.key}, ${template.version}, ${hash}) ON CONFLICT (name) DO NOTHING RETURNING *`,
596
+ );
597
+ if (!row) return;
598
+ for (const ref of fields.references)
599
+ await tx`INSERT INTO ai.skill_references (skill_id, path, content)
600
+ VALUES (${row!.id}::uuid, ${ref.path}, ${ref.content})`;
601
+ await tx`UPDATE ai.skill_seeds SET skill_id = ${row!.id}::uuid WHERE key = ${template.key}`;
602
+ await createSkillAccess(row!.id, { principal: { type: "authenticated" }, permission: "read" }, tx);
477
603
  });
478
604
  },
479
605
 
@@ -510,39 +636,48 @@ export const aiSkills = {
510
636
  });
511
637
  },
512
638
 
513
- async list(subject: AccessSubject | null): Promise<AiSkillSummary[]> {
514
- const match = accessMatch(subject);
515
- const userId = subject?.type === "user" ? subject.userId : null;
516
- const rows = await sql<SkillSummaryRow[]>`
517
- SELECT skill.*,
518
- (array_agg(access.permission ORDER BY
519
- CASE access.permission WHEN 'admin' THEN 3 WHEN 'write' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC
520
- ))[1] AS permission,
521
- count(DISTINCT reference.path)::int AS reference_count,
522
- (disabled.user_id IS NULL) AS enabled
523
- FROM ai.skills skill
524
- JOIN ai.skill_access skill_access ON skill_access.skill_id = skill.id
525
- JOIN auth.access access ON access.id = skill_access.access_id
526
- LEFT JOIN ai.skill_references reference ON reference.skill_id = skill.id
527
- LEFT JOIN ai.skill_user_disabled disabled
528
- ON disabled.skill_id = skill.id AND disabled.user_id = ${userId}::uuid
529
- WHERE access.permission <> 'none' AND ${match}
530
- GROUP BY skill.id, disabled.user_id
531
- ORDER BY skill.name, skill.id
532
- LIMIT 200
533
- `;
534
- return rows.map((row) => ({
535
- id: row.id,
536
- shortId: row.short_id,
537
- name: row.name,
538
- description: row.description,
539
- permission: row.permission,
540
- revision: Number(row.revision),
541
- referenceCount: Number(row.reference_count),
542
- enabled: row.enabled,
543
- createdAt: iso(row.created_at),
544
- updatedAt: iso(row.updated_at),
545
- }));
639
+ async list(subject: AccessSubject | null, options: SkillListOptions = {}): Promise<AiSkillSummary[]> {
640
+ return listSkillSummaries(subject, options);
641
+ },
642
+
643
+ async projectSkills(
644
+ projectId: string,
645
+ subject: AccessSubject | null,
646
+ options: { query?: string; page?: number; available?: boolean } = {},
647
+ ) {
648
+ if (!(await aiProjects.get(projectId, subject, options.available ? "admin" : "read"))) return null;
649
+ const page = Math.max(1, Math.min(100000, Math.trunc(options.page ?? 1)));
650
+ const skills = await listSkillSummaries(subject, {
651
+ query: options.query,
652
+ limit: 31,
653
+ offset: (page - 1) * 30,
654
+ project: { id: projectId, linked: !options.available },
655
+ manageOnly: options.available,
656
+ });
657
+ return { items: skills.slice(0, 30), hasNext: skills.length > 30, page };
658
+ },
659
+
660
+ async linkProject(skillId: string, projectId: string, linked: boolean, subject: AccessSubject | null): Promise<boolean> {
661
+ if (!(await aiProjects.get(projectId, subject, "admin"))) return false;
662
+ return sql.begin(async (tx) => {
663
+ const [row] = await tx`SELECT id FROM ai.skills WHERE id=${skillId}::uuid FOR UPDATE`;
664
+ if (!row || !hasPermission(await permissionFor(skillId, subject, tx), "admin")) return false;
665
+ if (linked)
666
+ await tx`INSERT INTO ai.project_skills(project_id,skill_id) VALUES(${projectId}::uuid,${skillId}::uuid) ON CONFLICT DO NOTHING`;
667
+ else await tx`DELETE FROM ai.project_skills WHERE project_id=${projectId}::uuid AND skill_id=${skillId}::uuid`;
668
+ return true;
669
+ });
670
+ },
671
+
672
+ async linkedProjects(skillId: string, subject: AccessSubject | null) {
673
+ if (!(await this.get(skillId, subject, "admin"))) return null;
674
+ return linkedSkillProjects(skillId, subject);
675
+ },
676
+
677
+ async search(subject: AccessSubject | null, query: string, limit = 30): Promise<{ skills: AiSkillSummary[]; more: boolean }> {
678
+ const size = Math.max(1, Math.min(200, limit));
679
+ const skills = await aiSkills.list(subject, { query, enabledOnly: true, limit: size + 1 });
680
+ return { skills: skills.slice(0, size), more: skills.length > size };
546
681
  },
547
682
 
548
683
  async get(skillId: string, subject: AccessSubject | null, required: AiSkillPermission = "read"): Promise<AiSkill | null> {
@@ -607,39 +742,95 @@ export const aiSkills = {
607
742
  },
608
743
 
609
744
  async listAccess(skillId: string, subject: AccessSubject | null): Promise<AiSkillAccess[] | null> {
610
- if (!(await requireSkill(await getRow(skillId), subject, "admin"))) return null;
611
- return listSkillAccess(skillId);
745
+ return sql.begin(async (tx) => {
746
+ const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id=${skillId}::uuid FOR UPDATE`;
747
+ if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "admin")) return null;
748
+ return listSkillAccess(skillId, tx);
749
+ });
612
750
  },
613
751
 
614
752
  async grantAccess(
615
753
  skillId: string,
616
754
  subject: AccessSubject | null,
617
755
  input: { principal: Principal; permission: AiSkillPermission },
756
+ expectedAccessRevision?: string,
618
757
  ): Promise<AiSkillAccess | null> {
619
758
  return sql.begin(async (tx) => {
620
759
  const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
621
760
  if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "admin")) return null;
761
+ await checkAccessRevision(skillId, expectedAccessRevision, tx);
622
762
  return createSkillAccess(skillId, input, tx);
623
763
  });
624
764
  },
625
765
 
626
- async updateAccess(skillId: string, accessId: string, subject: AccessSubject | null, permission: AiSkillPermission): Promise<boolean> {
766
+ async updateAccess(
767
+ skillId: string,
768
+ accessId: string,
769
+ subject: AccessSubject | null,
770
+ permission: AiSkillPermission,
771
+ expectedAccessRevision?: string,
772
+ ): Promise<boolean> {
627
773
  return sql.begin(async (tx) => {
628
774
  const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
629
- return row && hasPermission(await permissionFor(row.id, subject, tx), "admin")
630
- ? updateSkillAccess(skillId, accessId, permission, tx)
631
- : false;
775
+ if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "admin")) return false;
776
+ await checkAccessRevision(skillId, expectedAccessRevision, tx);
777
+ return updateSkillAccess(skillId, accessId, permission, tx);
632
778
  });
633
779
  },
634
780
 
635
- async revokeAccess(skillId: string, accessId: string, subject: AccessSubject | null): Promise<boolean> {
781
+ async revokeAccess(skillId: string, accessId: string, subject: AccessSubject | null, expectedAccessRevision?: string): Promise<boolean> {
636
782
  return sql.begin(async (tx) => {
637
783
  const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
638
- return row && hasPermission(await permissionFor(row.id, subject, tx), "admin") ? revokeSkillAccess(skillId, accessId, tx) : false;
784
+ if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "admin")) return false;
785
+ await checkAccessRevision(skillId, expectedAccessRevision, tx);
786
+ return revokeSkillAccess(skillId, accessId, tx);
639
787
  });
640
788
  },
641
789
 
642
790
  admin: {
791
+ linkedProjects: linkedSkillProjects,
792
+ // Platform-owned recovery API: callers must enforce platform-admin authorization.
793
+ async applyTemplate(
794
+ skillId: string,
795
+ input: {
796
+ templateId: string;
797
+ templateVersion: number;
798
+ expectedRevision: number;
799
+ mode: "associate" | "reset";
800
+ },
801
+ ): Promise<boolean> {
802
+ const template = getBuiltinAiSkillTemplates().find((entry) => entry.key === input.templateId);
803
+ if (!template) throw new AiSkillInputError("Unknown Skill template.");
804
+ if (template.version !== input.templateVersion) throw new AiSkillRevisionConflictError();
805
+ const { hash } = validateTemplate(template);
806
+ return sql.begin(async (tx) => {
807
+ await tx`INSERT INTO ai.skill_seeds (key, catalog_version) VALUES (${template.key}, ${template.version})
808
+ ON CONFLICT (key) DO NOTHING`;
809
+ const [seed] = await tx<{ skill_id: string | null; catalog_version: number }[]>`
810
+ SELECT skill_id, catalog_version FROM ai.skill_seeds WHERE key = ${template.key} FOR UPDATE`;
811
+ const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
812
+ if (!row) return false;
813
+ if (
814
+ Number(row.revision) !== input.expectedRevision ||
815
+ seed!.catalog_version > template.version ||
816
+ Number(row.template_version) > template.version
817
+ )
818
+ throw new AiSkillRevisionConflictError();
819
+ if (seed!.skill_id && seed!.skill_id !== row.id)
820
+ throw new AiSkillInputError("This template is already linked, possibly to a deleted Skill.");
821
+ if (input.mode === "associate") {
822
+ if (row.template_id) throw new AiSkillInputError("This Skill already has a template.");
823
+ await tx`UPDATE ai.skills SET template_id = ${template.key}, template_version = ${template.version},
824
+ template_hash = ${hash}, managed_key = NULL, revision = revision + 1, updated_at = now() WHERE id = ${row.id}::uuid`;
825
+ } else {
826
+ if (row.template_id !== template.key) throw new AiSkillInputError("Associate this Skill with its template first.");
827
+ await replaceTemplate(row, template, tx);
828
+ }
829
+ await tx`UPDATE ai.skill_seeds SET skill_id = ${row.id}::uuid, catalog_version = ${template.version} WHERE key = ${template.key}`;
830
+ return true;
831
+ });
832
+ },
833
+
643
834
  async list(params: {
644
835
  search?: string;
645
836
  page?: number;
@@ -659,6 +850,8 @@ export const aiSkills = {
659
850
  `;
660
851
  const rows = await sql<AdminSkillRow[]>`
661
852
  SELECT skill.*,
853
+ (SELECT COALESCE(jsonb_agg(jsonb_build_object('path', content.path, 'content', content.content)), '[]'::jsonb)
854
+ FROM ai.skill_references content WHERE content.skill_id = skill.id) AS content_references,
662
855
  count(DISTINCT reference.path)::int AS reference_count,
663
856
  count(DISTINCT skill_access.access_id)::int AS access_count,
664
857
  count(DISTINCT skill_access.access_id) FILTER (WHERE access.permission = 'admin')::int AS admin_count
@@ -704,6 +897,8 @@ export const aiSkills = {
704
897
  async getByShortId(shortId: string): Promise<AiSkillAdminListItem | null> {
705
898
  const rows = await sql<AdminSkillRow[]>`
706
899
  SELECT skill.*,
900
+ (SELECT COALESCE(jsonb_agg(jsonb_build_object('path', content.path, 'content', content.content)), '[]'::jsonb)
901
+ FROM ai.skill_references content WHERE content.skill_id = skill.id) AS content_references,
707
902
  count(DISTINCT reference.path)::int AS reference_count,
708
903
  count(DISTINCT skill_access.access_id)::int AS access_count,
709
904
  count(DISTINCT skill_access.access_id) FILTER (WHERE access.permission = 'admin')::int AS admin_count
@@ -885,17 +1080,15 @@ export const aiSkills = {
885
1080
  },
886
1081
 
887
1082
  async listTurnFiles(turnId: string, subject: AccessSubject | null): Promise<AiSkillFileToolStat[]> {
888
- const match = accessMatch(subject);
889
1083
  const userId = subject?.type === "user" ? subject.userId : null;
890
1084
  const rows = await sql<SnapshotRow[]>`
891
1085
  SELECT DISTINCT snapshot.skill_name, snapshot.description, snapshot.revision, snapshot.instructions,
892
1086
  snapshot.files, snapshot.loaded_at
893
1087
  FROM ai.turn_skill_snapshots snapshot
894
- JOIN ai.skill_access skill_access ON skill_access.skill_id = snapshot.skill_id
895
- JOIN auth.access access ON access.id = skill_access.access_id
1088
+ JOIN LATERAL (${effectivePermission(sql`snapshot.skill_id`, subject)}) access ON true
896
1089
  LEFT JOIN ai.skill_user_disabled disabled
897
1090
  ON disabled.skill_id = snapshot.skill_id AND disabled.user_id = ${userId}::uuid
898
- WHERE snapshot.turn_id = ${turnId}::uuid AND access.permission <> 'none' AND ${match}
1091
+ WHERE snapshot.turn_id = ${turnId}::uuid AND access.permission <> 'none'
899
1092
  AND disabled.user_id IS NULL
900
1093
  ORDER BY snapshot.skill_name
901
1094
  `;
@@ -914,21 +1107,23 @@ export const aiSkills = {
914
1107
  const stat = files.find((file) => file.path === path);
915
1108
  if (!stat) return null;
916
1109
  const [skillName] = path.split("/", 1);
917
- const match = accessMatch(subject);
918
1110
  const userId = subject?.type === "user" ? subject.userId : null;
919
1111
  const [row] = await sql<SnapshotRow[]>`
920
1112
  SELECT DISTINCT snapshot.skill_name, snapshot.description, snapshot.revision, snapshot.instructions,
921
1113
  snapshot.files, snapshot.loaded_at
922
1114
  FROM ai.turn_skill_snapshots snapshot
923
- JOIN ai.skill_access skill_access ON skill_access.skill_id = snapshot.skill_id
924
- JOIN auth.access access ON access.id = skill_access.access_id
1115
+ JOIN LATERAL (${effectivePermission(sql`snapshot.skill_id`, subject)}) access ON true
925
1116
  LEFT JOIN ai.skill_user_disabled disabled
926
1117
  ON disabled.skill_id = snapshot.skill_id AND disabled.user_id = ${userId}::uuid
927
1118
  WHERE snapshot.turn_id = ${turnId}::uuid AND snapshot.skill_name = ${skillName}
928
- AND access.permission <> 'none' AND ${match} AND disabled.user_id IS NULL
1119
+ AND access.permission <> 'none' AND disabled.user_id IS NULL
929
1120
  LIMIT 1
930
1121
  `;
931
1122
  const file = row ? snapshotFiles(row.files).find((entry) => entry.path === path) : null;
932
1123
  return file ? { ...stat, bytes: new TextEncoder().encode(file.content) } : null;
933
1124
  },
934
1125
  };
1126
+
1127
+ export const seedCloudAiSkills = async (): Promise<void> => {
1128
+ for (const seed of getBuiltinAiSkillTemplates()) await aiSkills.seedOnce(seed);
1129
+ };