@k2b/cloud 0.29.0 → 0.30.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 (92) hide show
  1. package/package.json +5 -3
  2. package/src/_internal/app-roles.ts +14 -0
  3. package/src/_internal/capabilities.ts +111 -3
  4. package/src/_internal/capability-sentences.ts +413 -0
  5. package/src/_internal/define-app.ts +10 -1
  6. package/src/_internal/registry-validation.ts +5 -0
  7. package/src/access/PermissionEditor.tsx +43 -24
  8. package/src/ai/app-skill-store.ts +298 -0
  9. package/src/ai/app-skills.ts +105 -0
  10. package/src/ai/browser.ts +10 -0
  11. package/src/ai/capabilities.ts +68 -9
  12. package/src/ai/chart-block.ts +380 -0
  13. package/src/ai/chat/action-sentences.ts +49 -0
  14. package/src/ai/chat/blocks.tsx +203 -185
  15. package/src/ai/chat/chart-block.tsx +79 -0
  16. package/src/ai/chat/live-turn.browser-harness.tsx +5 -1
  17. package/src/ai/chat/message-utils.ts +3 -0
  18. package/src/ai/chat/messages.ts +12 -26
  19. package/src/ai/chat/tool-disclosure.tsx +21 -14
  20. package/src/ai/chat/tool-groups.ts +64 -37
  21. package/src/ai/chat/tool-names.ts +2 -0
  22. package/src/ai/chat/turn-layout.ts +4 -4
  23. package/src/ai/chat/turn-view.tsx +141 -69
  24. package/src/ai/code-runtime-tools.ts +59 -44
  25. package/src/ai/default-tool-contracts.ts +198 -0
  26. package/src/ai/default-tools.ts +25 -1
  27. package/src/ai/executor.ts +19 -21
  28. package/src/ai/index.ts +9 -1
  29. package/src/ai/migrate.ts +59 -12
  30. package/src/ai/protocol.ts +9 -4
  31. package/src/ai/runtime-tools.ts +1 -0
  32. package/src/ai/skill-content.ts +54 -0
  33. package/src/ai/skills-routes.ts +22 -13
  34. package/src/ai/skills.ts +177 -231
  35. package/src/ai/store.ts +20 -13
  36. package/src/ai/tool-sentences.ts +38 -0
  37. package/src/ai/types.ts +10 -2
  38. package/src/api/admin-ai-skills.ts +35 -16
  39. package/src/api/admin-outgoing-mail.ts +18 -0
  40. package/src/api/fanout.ts +128 -0
  41. package/src/api/help.ts +8 -3
  42. package/src/api/mcp.ts +4 -1
  43. package/src/api/search.ts +31 -106
  44. package/src/api/widgets.ts +259 -100
  45. package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
  46. package/src/browser/ndjson.ts +34 -0
  47. package/src/browser/reload.ts +46 -0
  48. package/src/browser/search-stream.ts +4 -26
  49. package/src/browser/widgets.ts +28 -0
  50. package/src/capabilities/server.ts +20 -2
  51. package/src/cli/access.ts +48 -14
  52. package/src/cli/admin/ai-skills.ts +59 -39
  53. package/src/cli/admin/outgoing-mail.ts +30 -2
  54. package/src/contracts/announcement-cookie.ts +62 -0
  55. package/src/contracts/announcements.ts +14 -52
  56. package/src/contracts/capabilities.ts +24 -0
  57. package/src/contracts/outgoing-mail.ts +56 -11
  58. package/src/contracts/pwa-paths.ts +7 -2
  59. package/src/contracts/pwa.ts +2 -7
  60. package/src/contracts/registry.ts +1 -0
  61. package/src/contracts/shared.ts +5 -0
  62. package/src/contracts/widgets.ts +20 -0
  63. package/src/index.ts +1 -0
  64. package/src/server/help.ts +8 -1
  65. package/src/services/accounts/users.ts +15 -0
  66. package/src/services/help/index.ts +18 -5
  67. package/src/services/help/types.ts +3 -2
  68. package/src/services/identity/constants.ts +2 -0
  69. package/src/services/identity/key-ring.ts +123 -42
  70. package/src/services/identity/runtime-config.ts +13 -2
  71. package/src/services/outbox.ts +24 -9
  72. package/src/services/outgoing-mail/index.ts +33 -5
  73. package/src/services/outgoing-mail/messages.ts +60 -19
  74. package/src/services/outgoing-mail/store.ts +63 -4
  75. package/src/shared/network-address.ts +1 -0
  76. package/src/ssr/AppLaunchpadPanel.tsx +12 -3
  77. package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
  78. package/src/ssr/GlobalSearchDialog.tsx +1 -43
  79. package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
  80. package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
  81. package/src/ssr/PwaRuntime.island.tsx +1 -1
  82. package/src/ssr/app-navigation.ts +5 -7
  83. package/src/ssr/global-search-host.tsx +55 -0
  84. package/src/ssr/help.ts +3 -3
  85. package/src/ssr/rail-context.ts +4 -3
  86. package/src/ssr/runtime.ts +14 -1
  87. package/src/styles/effects.css +142 -0
  88. package/src/styles/utilities-markdown-table.css +5 -1
  89. package/src/ai/code-mode-skill.ts +0 -114
  90. package/src/ai/data-analysis-skill.ts +0 -10
  91. package/src/ai/grids-skill.ts +0 -158
  92. package/src/ai/skill-seeds.ts +0 -560
package/src/ai/skills.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { createHash } from "node:crypto";
2
1
  import { type SQL, type SQLQuery, sql } from "bun";
3
2
  import { AuthenticatedPrincipalSchema, type ServiceAccountKind } from "../contracts/shared";
4
3
  import type { AccessSubject } from "../server";
@@ -11,10 +10,12 @@ import {
11
10
  type Principal,
12
11
  } from "../server/services/access";
13
12
  import { accessRevision } from "../server/services/access-revision";
14
- import { toPgUuidArray } from "../services/postgres";
13
+ import { getRoles } from "../services/accounts/users";
14
+ import { toPgTextArray, toPgUuidArray } from "../services/postgres";
15
15
  import { mountAiSkillFilePath } from "./file-mount";
16
16
  import { aiProjects } from "./projects";
17
17
  import { AI_SHORT_ID_PATTERN, withAiShortIdForDb } from "./short-id";
18
+ import { type AiSkillContent, appSkillStatus, contentHash } from "./skill-content";
18
19
  import {
19
20
  type AiSkillExtraFrontmatter,
20
21
  type AiSkillReferenceInput,
@@ -26,7 +27,6 @@ import {
26
27
  validateAiSkillReferences,
27
28
  } from "./skill-format";
28
29
  import { aiSkillSearchSql, withAiSkillSearch } from "./skill-search";
29
- import { getBuiltinAiSkillTemplates } from "./skill-seeds";
30
30
  import type { AiSkillFileToolContent, AiSkillFileToolStat } from "./types";
31
31
 
32
32
  export type AiSkillPermission = Exclude<PermissionLevel, "none">;
@@ -34,6 +34,7 @@ export type AiSkillPermission = Exclude<PermissionLevel, "none">;
34
34
  export type AiSkillReference = AiSkillReferenceInput;
35
35
 
36
36
  export type AiSkillSummary = {
37
+ source: { appId: string; appName: string } | null;
37
38
  id: string;
38
39
  shortId: string;
39
40
  name: string;
@@ -64,22 +65,16 @@ export type AiSkillAccess = {
64
65
  createdAt: string;
65
66
  };
66
67
 
67
- export type AiSkillTemplate = {
68
- key: string;
69
- version: number;
70
- name: string;
71
- description: string;
72
- instructions: string;
73
- extraFrontmatter?: AiSkillExtraFrontmatter;
74
- references?: readonly AiSkillReferenceInput[];
75
- };
76
-
77
68
  export type AiSkillAdminListItem = {
78
69
  revision: number;
79
- templateId: string | null;
80
- templateVersion: number | null;
81
- currentTemplateVersion: number | null;
82
- templateStatus: "current" | "modified" | "update_available" | null;
70
+ source: {
71
+ appId: string;
72
+ appName: string;
73
+ status: "current" | "modified" | "update_available";
74
+ available: boolean;
75
+ /** Content hash of the app's latest version; reset requires it. Null until the app publishes after the upgrade. */
76
+ appVersion: string | null;
77
+ } | null;
83
78
  id: string;
84
79
  shortId: string;
85
80
  name: string;
@@ -106,13 +101,10 @@ export type AiLoadedSkillSnapshot = {
106
101
  loadedAt: string;
107
102
  };
108
103
 
109
- type SkillRow = {
104
+ export type SkillRow = {
110
105
  id: string;
111
106
  short_id: string;
112
107
  managed_key: string | null;
113
- template_id: string | null;
114
- template_version: number | null;
115
- template_hash: string | null;
116
108
  name: string;
117
109
  description: string;
118
110
  instructions: string;
@@ -136,12 +128,19 @@ type SkillAccessRow = {
136
128
  };
137
129
 
138
130
  type SkillSummaryRow = Pick<SkillRow, "id" | "short_id" | "name" | "description" | "revision" | "created_at" | "updated_at"> & {
131
+ app_id: string | null;
132
+ app_name: string | null;
139
133
  permission: AiSkillPermission;
140
134
  reference_count: number;
141
135
  enabled: boolean;
142
136
  };
143
137
 
144
138
  type AdminSkillRow = SkillRow & {
139
+ app_id: string | null;
140
+ app_name: string | null;
141
+ source_hash: string | null;
142
+ applied_hash: string | null;
143
+ available: boolean;
145
144
  content_references: AiSkillReference[] | string;
146
145
  reference_count: number;
147
146
  access_count: number;
@@ -157,6 +156,13 @@ type SnapshotRow = {
157
156
  loaded_at: Date | string;
158
157
  };
159
158
 
159
+ export class AiSkillAppForbiddenError extends Error {
160
+ constructor(appName: string) {
161
+ super(`This Skill comes from the app ${appName}. A Cloud administrator can override it under Administration > AI Skills.`);
162
+ this.name = "AiSkillAppForbiddenError";
163
+ }
164
+ }
165
+
160
166
  export class AiSkillLastAdminError extends Error {
161
167
  constructor() {
162
168
  super("A skill must keep at least one admin access entry.");
@@ -165,8 +171,8 @@ export class AiSkillLastAdminError extends Error {
165
171
  }
166
172
 
167
173
  export class AiSkillRevisionConflictError extends Error {
168
- constructor() {
169
- super("This skill changed after it was opened. Reload it before saving.");
174
+ constructor(message = "This skill changed after it was opened. Reload it before saving.") {
175
+ super(message);
170
176
  this.name = "AiSkillRevisionConflictError";
171
177
  }
172
178
  }
@@ -204,7 +210,7 @@ const accessMatch = (subject: AccessSubject | null): SQLQuery =>
204
210
  },
205
211
  });
206
212
 
207
- const getRow = async (skillId: string, db: SQL = sql): Promise<SkillRow | null> =>
213
+ export const getRow = async (skillId: string, db: SQL = sql): Promise<SkillRow | null> =>
208
214
  (await db<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid`)[0] ?? null;
209
215
 
210
216
  const getRowByShortId = async (shortId: string, db: SQL = sql): Promise<SkillRow | null> =>
@@ -213,26 +219,58 @@ const getRowByShortId = async (shortId: string, db: SQL = sql): Promise<SkillRow
213
219
  const getRowByName = async (name: string, db: SQL = sql): Promise<SkillRow | null> =>
214
220
  (await db<SkillRow[]>`SELECT * FROM ai.skills WHERE name = ${name}`)[0] ?? null;
215
221
 
216
- // Project links grant read only. Their creator's later permissions are irrelevant.
217
- const effectivePermission = (skillId: SQLQuery, subject: AccessSubject | null) => sql`
222
+ /**
223
+ * Resolve before opening a transaction: role lookup uses its own pool connection, and a transaction that waits for a
224
+ * second connection can exhaust the pool under concurrent requests.
225
+ */
226
+ const subjectRoles = async (subject: AccessSubject | null): Promise<string[]> =>
227
+ subject?.type === "user" ? await getRoles(subject.userId) : [];
228
+
229
+ // App availability applies to every grant. Only explicit skill grants bypass its audience.
230
+ const effectivePermission = (skillId: SQLQuery, subject: AccessSubject | null, roles: readonly string[]) => sql`
218
231
  SELECT permission FROM (
219
- SELECT access.permission FROM ai.skill_access link JOIN auth.access access ON access.id=link.access_id
232
+ SELECT access.permission, (access.user_id IS NOT NULL OR access.group_id IS NOT NULL OR access.service_account_id IS NOT NULL) AS direct
233
+ FROM ai.skill_access link JOIN auth.access access ON access.id=link.access_id
220
234
  WHERE link.skill_id=${skillId} AND ${accessMatch(subject)}
221
235
  UNION ALL
222
- SELECT 'read'::auth.permission_level FROM ai.project_skills link
236
+ SELECT 'read'::auth.permission_level, false AS direct FROM ai.project_skills link
223
237
  JOIN ai.project_access project_access ON project_access.project_id=link.project_id
224
238
  JOIN auth.access access ON access.id=project_access.access_id
225
239
  WHERE link.skill_id=${skillId} AND access.permission <> 'none' AND ${accessMatch(subject)}
226
- ) grants ORDER BY CASE permission WHEN 'admin' THEN 3 WHEN 'write' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC LIMIT 1
240
+ ) grants WHERE NOT EXISTS (
241
+ SELECT 1 FROM ai.app_skills app WHERE app.skill_id=${skillId}
242
+ AND (NOT app.available OR (NOT grants.direct AND app.required_roles IS NOT NULL AND NOT(app.required_roles && ${toPgTextArray([...roles])}::text[])))
243
+ ) ORDER BY CASE permission WHEN 'admin' THEN 3 WHEN 'write' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC LIMIT 1
227
244
  `;
228
245
 
229
- const permissionFor = async (skillId: string, subject: AccessSubject | null, db: SQL = sql): Promise<AiSkillPermission | "none"> => {
246
+ const permissionFor = async (
247
+ skillId: string,
248
+ subject: AccessSubject | null,
249
+ db: SQL,
250
+ roles: readonly string[],
251
+ ): Promise<AiSkillPermission | "none"> => {
230
252
  const [row] = await db<
231
253
  { permission: AiSkillPermission | null }[]
232
- >`SELECT (${effectivePermission(sql`${skillId}::uuid`, subject)}) AS permission`;
254
+ >`SELECT (${effectivePermission(sql`${skillId}::uuid`, subject, roles)}) AS permission`;
233
255
  return row?.permission ?? "none";
234
256
  };
235
257
 
258
+ const canMutate = async (
259
+ skillId: string,
260
+ subject: AccessSubject | null,
261
+ required: AiSkillPermission,
262
+ db: SQL,
263
+ roles: readonly string[],
264
+ ): Promise<boolean> => {
265
+ const permission = await permissionFor(skillId, subject, db, roles);
266
+ if (hasPermission(permission, required)) return true;
267
+ if (hasPermission(permission, "read")) {
268
+ const [app] = await db<{ app_name: string }[]>`SELECT app_name FROM ai.app_skills WHERE skill_id=${skillId}::uuid`;
269
+ if (app) throw new AiSkillAppForbiddenError(app.app_name);
270
+ }
271
+ return false;
272
+ };
273
+
236
274
  const enabledFor = async (skillId: string, subject: AccessSubject | null, db: SQL = sql): Promise<boolean> => {
237
275
  if (subject?.type !== "user") return true;
238
276
  const [disabled] = await db<{ disabled: boolean }[]>`
@@ -243,18 +281,22 @@ const enabledFor = async (skillId: string, subject: AccessSubject | null, db: SQ
243
281
  return !disabled;
244
282
  };
245
283
 
246
- const listReferences = async (skillId: string, db: SQL = sql): Promise<AiSkillReference[]> =>
284
+ export const listReferences = async (skillId: string, db: SQL = sql): Promise<AiSkillReference[]> =>
247
285
  db<AiSkillReference[]>`
248
286
  SELECT path, content FROM ai.skill_references
249
287
  WHERE skill_id = ${skillId}::uuid
250
288
  ORDER BY path
251
289
  `;
252
290
 
253
- const toSkill = async (row: SkillRow, subject: AccessSubject | null, db: SQL = sql): Promise<AiSkill | null> => {
254
- const permission = await permissionFor(row.id, subject, db);
291
+ const toSkill = async (row: SkillRow, subject: AccessSubject | null, db: SQL, roles: readonly string[]): Promise<AiSkill | null> => {
292
+ const permission = await permissionFor(row.id, subject, db, roles);
255
293
  if (permission === "none") return null;
256
294
  const references = await listReferences(row.id, db);
295
+ const [source] = await db<
296
+ { appId: string; appName: string }[]
297
+ >`SELECT app_id AS "appId", app_name AS "appName" FROM ai.app_skills WHERE skill_id=${row.id}::uuid`;
257
298
  return {
299
+ source: source ?? null,
258
300
  id: row.id,
259
301
  shortId: row.short_id,
260
302
  name: row.name,
@@ -274,11 +316,14 @@ const toSkill = async (row: SkillRow, subject: AccessSubject | null, db: SQL = s
274
316
  const requireSkill = async (
275
317
  row: SkillRow | null,
276
318
  subject: AccessSubject | null,
277
- required: AiSkillPermission = "read",
278
- db: SQL = sql,
319
+ required: AiSkillPermission,
320
+ db: SQL,
321
+ roles: readonly string[],
279
322
  ): Promise<AiSkill | null> => {
280
323
  if (!row) return null;
281
- const skill = await toSkill(row, subject, db);
324
+ const skill = await toSkill(row, subject, db, roles);
325
+ if (skill && !hasPermission(skill.permission, required) && skill.source && required !== "read")
326
+ throw new AiSkillAppForbiddenError(skill.source.appName);
282
327
  return skill && hasPermission(skill.permission, required) ? skill : null;
283
328
  };
284
329
 
@@ -317,7 +362,7 @@ const listSkillAccess = async (skillId: string, db: SQL = sql): Promise<AiSkillA
317
362
  }));
318
363
  };
319
364
 
320
- const createSkillAccess = async (
365
+ export const createSkillAccess = async (
321
366
  skillId: string,
322
367
  input: { principal: Principal; permission: AiSkillPermission },
323
368
  db: SQL,
@@ -408,29 +453,7 @@ const validatedFields = (input: {
408
453
  }
409
454
  };
410
455
 
411
- // JSON object key order and reference insertion order are not content changes.
412
- const canonicalJson = (value: unknown): string => {
413
- if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
414
- if (value !== null && typeof value === "object") {
415
- return `{${Object.entries(value)
416
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
417
- .map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`)
418
- .join(",")}}`;
419
- }
420
- return JSON.stringify(value);
421
- };
422
-
423
- const contentHash = (fields: ReturnType<typeof validatedFields>): string =>
424
- createHash("sha256")
425
- .update(
426
- canonicalJson({
427
- ...fields,
428
- references: [...fields.references].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)),
429
- }),
430
- )
431
- .digest("hex");
432
-
433
- const rowContentHash = async (row: SkillRow, db: SQL = sql): Promise<string> =>
456
+ export const rowContentHash = async (row: SkillRow, db: SQL = sql): Promise<string> =>
434
457
  contentHash({
435
458
  name: row.name,
436
459
  description: row.description,
@@ -439,23 +462,13 @@ const rowContentHash = async (row: SkillRow, db: SQL = sql): Promise<string> =>
439
462
  references: await listReferences(row.id, db),
440
463
  });
441
464
 
442
- const validateTemplate = (input: AiSkillTemplate) => {
443
- if (!input.key.trim() || !Number.isSafeInteger(input.version) || input.version < 1 || input.version > 2147483647)
444
- throw new AiSkillInputError("A template needs a stable ID and a positive integer version.");
445
- const fields = validatedFields(input);
446
- return { fields, hash: contentHash(fields) };
447
- };
448
-
449
- const replaceTemplate = async (row: SkillRow, template: AiSkillTemplate, db: SQL): Promise<void> => {
450
- const { fields, hash } = validateTemplate(template);
465
+ export const replaceSkillContent = async (row: SkillRow, fields: AiSkillContent, db: SQL): Promise<void> => {
451
466
  await db`UPDATE ai.skills SET name = ${fields.name}, description = ${fields.description},
452
467
  instructions = ${fields.instructions}, extra_frontmatter = (${JSON.stringify(fields.extraFrontmatter)}::text)::jsonb,
453
- template_id = ${template.key}, template_version = ${template.version}, template_hash = ${hash},
454
468
  revision = revision + 1, updated_at = now() WHERE id = ${row.id}::uuid`;
455
469
  await db`DELETE FROM ai.skill_references WHERE skill_id = ${row.id}::uuid`;
456
470
  for (const ref of fields.references)
457
- await db`INSERT INTO ai.skill_references (skill_id, path, content)
458
- VALUES (${row.id}::uuid, ${ref.path}, ${ref.content})`;
471
+ await db`INSERT INTO ai.skill_references (skill_id, path, content) VALUES (${row.id}::uuid, ${ref.path}, ${ref.content})`;
459
472
  };
460
473
 
461
474
  const skillSearchPattern = (search?: string): string | null => {
@@ -464,25 +477,25 @@ const skillSearchPattern = (search?: string): string | null => {
464
477
  };
465
478
 
466
479
  const toAdminSkill = (row: AdminSkillRow): AiSkillAdminListItem => {
467
- const template = getBuiltinAiSkillTemplates().find((entry) => entry.key === row.template_id);
480
+ const hash = contentHash({
481
+ name: row.name,
482
+ description: row.description,
483
+ instructions: row.instructions,
484
+ extraFrontmatter: jsonObject(row.extra_frontmatter),
485
+ references: snapshotFiles(row.content_references),
486
+ });
468
487
  return {
469
488
  revision: Number(row.revision),
470
- templateId: row.template_id,
471
- templateVersion: row.template_version,
472
- currentTemplateVersion: template?.version ?? null,
473
- templateStatus: !row.template_id
474
- ? null
475
- : template && template.version > Number(row.template_version)
476
- ? "update_available"
477
- : contentHash({
478
- name: row.name,
479
- description: row.description,
480
- instructions: row.instructions,
481
- extraFrontmatter: jsonObject(row.extra_frontmatter),
482
- references: snapshotFiles(row.content_references),
483
- }) === row.template_hash
484
- ? "current"
485
- : "modified",
489
+ source:
490
+ row.app_id && row.app_name
491
+ ? {
492
+ appId: row.app_id,
493
+ appName: row.app_name,
494
+ status: appSkillStatus(hash, row.source_hash, row.applied_hash),
495
+ appVersion: row.source_hash,
496
+ available: row.available,
497
+ }
498
+ : null,
486
499
  id: row.id,
487
500
  shortId: row.short_id,
488
501
  name: row.name,
@@ -512,6 +525,7 @@ async function listSkillSummaries(
512
525
  offset?: number;
513
526
  } = {},
514
527
  ): Promise<AiSkillSummary[]> {
528
+ const roles = await subjectRoles(subject);
515
529
  const userId = subject?.type === "user" ? subject.userId : null;
516
530
  const query = (options.query ?? "").trim().slice(0, 200);
517
531
  const limit = Math.max(1, Math.min(201, options.limit ?? 200));
@@ -519,11 +533,12 @@ async function listSkillSummaries(
519
533
  const search = aiSkillSearchSql(query, bm25);
520
534
  return sql<SkillSummaryRow[]>`
521
535
  SELECT skill.id, skill.short_id, skill.name, skill.description, skill.revision, skill.created_at, skill.updated_at,
522
- access.permission,
536
+ app_skill.app_id, app_skill.app_name, access.permission,
523
537
  count(DISTINCT reference.path)::int AS reference_count,
524
538
  (disabled.user_id IS NULL) AS enabled
525
539
  FROM ai.skills skill
526
- JOIN LATERAL (${effectivePermission(sql`skill.id`, subject)}) access ON true
540
+ LEFT JOIN ai.app_skills app_skill ON app_skill.skill_id=skill.id
541
+ JOIN LATERAL (${effectivePermission(sql`skill.id`, subject, roles)}) access ON true
527
542
  LEFT JOIN ai.skill_references reference ON reference.skill_id = skill.id
528
543
  LEFT JOIN ai.skill_user_disabled disabled
529
544
  ON disabled.skill_id = skill.id AND disabled.user_id = ${userId}::uuid
@@ -531,12 +546,13 @@ async function listSkillSummaries(
531
546
  AND (${!options.manageOnly} OR access.permission='admin')
532
547
  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`}
533
548
  AND (${!options.enabledOnly} OR disabled.user_id IS NULL) AND ${search.matches}
534
- GROUP BY skill.id, disabled.user_id, access.permission
549
+ GROUP BY skill.id, app_skill.key, disabled.user_id, access.permission
535
550
  ORDER BY ${search.rank} DESC, ${search.textRank} DESC, skill.name, skill.id
536
551
  LIMIT ${limit} OFFSET ${options.offset ?? 0}
537
552
  `;
538
553
  });
539
554
  return rows.map((row) => ({
555
+ source: row.app_id && row.app_name ? { appId: row.app_id, appName: row.app_name } : null,
540
556
  id: row.id,
541
557
  shortId: row.short_id,
542
558
  name: row.name,
@@ -564,6 +580,33 @@ async function linkedSkillProjects(skillId: string, subject: AccessSubject | nul
564
580
 
565
581
  export type AiConversationSkillUse = { name: string; description: string; turns: number; lastLoadedAt: string };
566
582
 
583
+ const listSkillTurnFiles = async (
584
+ turnId: string,
585
+ subject: AccessSubject | null,
586
+ roles: readonly string[],
587
+ ): Promise<AiSkillFileToolStat[]> => {
588
+ const userId = subject?.type === "user" ? subject.userId : null;
589
+ const rows = await sql<SnapshotRow[]>`
590
+ SELECT DISTINCT snapshot.skill_name, snapshot.description, snapshot.revision, snapshot.instructions,
591
+ snapshot.files, snapshot.loaded_at
592
+ FROM ai.turn_skill_snapshots snapshot
593
+ JOIN LATERAL (${effectivePermission(sql`snapshot.skill_id`, subject, roles)}) access ON true
594
+ LEFT JOIN ai.skill_user_disabled disabled
595
+ ON disabled.skill_id = snapshot.skill_id AND disabled.user_id = ${userId}::uuid
596
+ WHERE snapshot.turn_id = ${turnId}::uuid AND access.permission <> 'none'
597
+ AND disabled.user_id IS NULL
598
+ ORDER BY snapshot.skill_name
599
+ `;
600
+ return rows.flatMap((row) =>
601
+ snapshotFiles(row.files).map((file) => ({
602
+ path: file.path,
603
+ mediaType: "text/markdown",
604
+ size: new TextEncoder().encode(file.content).byteLength,
605
+ updatedAt: iso(row.loaded_at),
606
+ })),
607
+ );
608
+ };
609
+
567
610
  export const aiSkills = {
568
611
  /**
569
612
  * Skills a chat loaded, from the revision snapshots its turns pinned, most recently loaded first. The caller resolves
@@ -589,54 +632,6 @@ export const aiSkills = {
589
632
  }));
590
633
  },
591
634
 
592
- async seedOnce(template: AiSkillTemplate): Promise<void> {
593
- const { fields, hash } = validateTemplate(template);
594
- await sql.begin(async (tx) => {
595
- const [claimed] = await tx`INSERT INTO ai.skill_seeds (key, catalog_version)
596
- VALUES (${template.key}, ${template.version}) ON CONFLICT (key) DO NOTHING RETURNING key`;
597
- // Seed lock always precedes the Skill lock, including administrator adoption/reset.
598
- const [seed] = await tx<{ skill_id: string | null; catalog_version: number }[]>`
599
- SELECT skill_id, catalog_version FROM ai.skill_seeds WHERE key = ${template.key} FOR UPDATE`;
600
- if (!seed || seed.catalog_version > template.version) return;
601
- await tx`UPDATE ai.skill_seeds SET catalog_version = ${template.version} WHERE key = ${template.key}`;
602
- if (seed.skill_id) {
603
- const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${seed.skill_id}::uuid FOR UPDATE`;
604
- if (!row || row.template_id !== template.key || Number(row.template_version) >= template.version) return;
605
- if ((await rowContentHash(row, tx)) !== row.template_hash) return;
606
- // A user may have claimed the new name. Keep the existing Skill rather than fail startup.
607
- const [collision] = await tx`SELECT id FROM ai.skills WHERE name = ${fields.name} AND id <> ${row.id}::uuid`;
608
- if (!collision) {
609
- try {
610
- await tx.savepoint((attempt) => replaceTemplate(row, template, attempt));
611
- } catch (error) {
612
- // A concurrent normal create may claim the name after our read.
613
- if (!(typeof error === "object" && error !== null && "constraint" in error && error.constraint === "idx_ai_skills_name"))
614
- throw error;
615
- }
616
- }
617
- return;
618
- }
619
- // Old seed markers and name collisions never authorize adoption or replacement.
620
- if (!claimed) return;
621
- const [collision] = await tx`SELECT id FROM ai.skills WHERE name = ${fields.name}`;
622
- if (collision) return;
623
- const [row] = await withAiShortIdForDb(
624
- tx,
625
- "idx_ai_skills_short_id",
626
- (attempt, shortId) => attempt<SkillRow[]>`
627
- INSERT INTO ai.skills (short_id, name, description, instructions, extra_frontmatter, template_id, template_version, template_hash)
628
- VALUES (${shortId}, ${fields.name}, ${fields.description}, ${fields.instructions},
629
- (${JSON.stringify(fields.extraFrontmatter)}::text)::jsonb, ${template.key}, ${template.version}, ${hash}) ON CONFLICT (name) DO NOTHING RETURNING *`,
630
- );
631
- if (!row) return;
632
- for (const ref of fields.references)
633
- await tx`INSERT INTO ai.skill_references (skill_id, path, content)
634
- VALUES (${row!.id}::uuid, ${ref.path}, ${ref.content})`;
635
- await tx`UPDATE ai.skill_seeds SET skill_id = ${row!.id}::uuid WHERE key = ${template.key}`;
636
- await createSkillAccess(row!.id, { principal: { type: "authenticated" }, permission: "read" }, tx);
637
- });
638
- },
639
-
640
635
  async create(input: {
641
636
  subject: AccessSubject;
642
637
  name: string;
@@ -646,6 +641,7 @@ export const aiSkills = {
646
641
  references?: readonly AiSkillReferenceInput[];
647
642
  }): Promise<AiSkill> {
648
643
  const fields = validatedFields(input);
644
+ const roles = await subjectRoles(input.subject);
649
645
  return sql.begin(async (tx) => {
650
646
  const rows = await withAiShortIdForDb(
651
647
  tx,
@@ -666,7 +662,7 @@ export const aiSkills = {
666
662
  `;
667
663
  }
668
664
  await createSkillAccess(rows[0]!.id, { principal: principalForSubject(input.subject), permission: "admin" }, tx);
669
- return (await toSkill(rows[0]!, input.subject, tx))!;
665
+ return (await toSkill(rows[0]!, input.subject, tx, roles))!;
670
666
  });
671
667
  },
672
668
 
@@ -693,9 +689,10 @@ export const aiSkills = {
693
689
 
694
690
  async linkProject(skillId: string, projectId: string, linked: boolean, subject: AccessSubject | null): Promise<boolean> {
695
691
  if (!(await aiProjects.get(projectId, subject, "admin"))) return false;
692
+ const roles = await subjectRoles(subject);
696
693
  return sql.begin(async (tx) => {
697
694
  const [row] = await tx`SELECT id FROM ai.skills WHERE id=${skillId}::uuid FOR UPDATE`;
698
- if (!row || !hasPermission(await permissionFor(skillId, subject, tx), "admin")) return false;
695
+ if (!row || !(await canMutate(skillId, subject, "admin", tx, roles))) return false;
699
696
  if (linked)
700
697
  await tx`INSERT INTO ai.project_skills(project_id,skill_id) VALUES(${projectId}::uuid,${skillId}::uuid) ON CONFLICT DO NOTHING`;
701
698
  else await tx`DELETE FROM ai.project_skills WHERE project_id=${projectId}::uuid AND skill_id=${skillId}::uuid`;
@@ -715,15 +712,15 @@ export const aiSkills = {
715
712
  },
716
713
 
717
714
  async get(skillId: string, subject: AccessSubject | null, required: AiSkillPermission = "read"): Promise<AiSkill | null> {
718
- return requireSkill(await getRow(skillId), subject, required);
715
+ return requireSkill(await getRow(skillId), subject, required, sql, await subjectRoles(subject));
719
716
  },
720
717
 
721
718
  async getByShortId(shortId: string, subject: AccessSubject | null, required: AiSkillPermission = "read"): Promise<AiSkill | null> {
722
- return requireSkill(await getRowByShortId(shortId), subject, required);
719
+ return requireSkill(await getRowByShortId(shortId), subject, required, sql, await subjectRoles(subject));
723
720
  },
724
721
 
725
722
  async getByName(name: string, subject: AccessSubject | null, required: AiSkillPermission = "read"): Promise<AiSkill | null> {
726
- return requireSkill(await getRowByName(name), subject, required);
723
+ return requireSkill(await getRowByName(name), subject, required, sql, await subjectRoles(subject));
727
724
  },
728
725
 
729
726
  async update(
@@ -739,9 +736,10 @@ export const aiSkills = {
739
736
  },
740
737
  ): Promise<AiSkill | null> {
741
738
  const fields = validatedFields(input);
739
+ const roles = await subjectRoles(subject);
742
740
  return sql.begin(async (tx) => {
743
741
  const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
744
- if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "write")) return null;
742
+ if (!row || !(await canMutate(row.id, subject, "write", tx, roles))) return null;
745
743
  if (Number(row.revision) !== input.expectedRevision) throw new AiSkillRevisionConflictError();
746
744
  const [updated] = await tx<SkillRow[]>`
747
745
  UPDATE ai.skills
@@ -758,14 +756,15 @@ export const aiSkills = {
758
756
  VALUES (${skillId}::uuid, ${reference.path}, ${reference.content})
759
757
  `;
760
758
  }
761
- return toSkill(updated!, subject, tx);
759
+ return toSkill(updated!, subject, tx, roles);
762
760
  });
763
761
  },
764
762
 
765
763
  async delete(skillId: string, subject: AccessSubject | null): Promise<boolean> {
764
+ const roles = await subjectRoles(subject);
766
765
  return sql.begin(async (tx) => {
767
766
  const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
768
- if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "admin")) return false;
767
+ if (!row || !(await canMutate(row.id, subject, "admin", tx, roles))) return false;
769
768
  const accessIds = (await tx<{ access_id: string }[]>`SELECT access_id FROM ai.skill_access WHERE skill_id = ${skillId}::uuid`).map(
770
769
  (entry) => entry.access_id,
771
770
  );
@@ -776,9 +775,10 @@ export const aiSkills = {
776
775
  },
777
776
 
778
777
  async listAccess(skillId: string, subject: AccessSubject | null): Promise<AiSkillAccess[] | null> {
778
+ const roles = await subjectRoles(subject);
779
779
  return sql.begin(async (tx) => {
780
780
  const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id=${skillId}::uuid FOR UPDATE`;
781
- if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "admin")) return null;
781
+ if (!row || !(await canMutate(row.id, subject, "admin", tx, roles))) return null;
782
782
  return listSkillAccess(skillId, tx);
783
783
  });
784
784
  },
@@ -789,9 +789,10 @@ export const aiSkills = {
789
789
  input: { principal: Principal; permission: AiSkillPermission },
790
790
  expectedAccessRevision?: string,
791
791
  ): Promise<AiSkillAccess | null> {
792
+ const roles = await subjectRoles(subject);
792
793
  return sql.begin(async (tx) => {
793
794
  const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
794
- if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "admin")) return null;
795
+ if (!row || !(await canMutate(row.id, subject, "admin", tx, roles))) return null;
795
796
  await checkAccessRevision(skillId, expectedAccessRevision, tx);
796
797
  return createSkillAccess(skillId, input, tx);
797
798
  });
@@ -804,18 +805,20 @@ export const aiSkills = {
804
805
  permission: AiSkillPermission,
805
806
  expectedAccessRevision?: string,
806
807
  ): Promise<boolean> {
808
+ const roles = await subjectRoles(subject);
807
809
  return sql.begin(async (tx) => {
808
810
  const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
809
- if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "admin")) return false;
811
+ if (!row || !(await canMutate(row.id, subject, "admin", tx, roles))) return false;
810
812
  await checkAccessRevision(skillId, expectedAccessRevision, tx);
811
813
  return updateSkillAccess(skillId, accessId, permission, tx);
812
814
  });
813
815
  },
814
816
 
815
817
  async revokeAccess(skillId: string, accessId: string, subject: AccessSubject | null, expectedAccessRevision?: string): Promise<boolean> {
818
+ const roles = await subjectRoles(subject);
816
819
  return sql.begin(async (tx) => {
817
820
  const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
818
- if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "admin")) return false;
821
+ if (!row || !(await canMutate(row.id, subject, "admin", tx, roles))) return false;
819
822
  await checkAccessRevision(skillId, expectedAccessRevision, tx);
820
823
  return revokeSkillAccess(skillId, accessId, tx);
821
824
  });
@@ -824,47 +827,6 @@ export const aiSkills = {
824
827
  admin: {
825
828
  linkedProjects: linkedSkillProjects,
826
829
  // Platform-owned recovery API: callers must enforce platform-admin authorization.
827
- async applyTemplate(
828
- skillId: string,
829
- input: {
830
- templateId: string;
831
- templateVersion: number;
832
- expectedRevision: number;
833
- mode: "associate" | "reset";
834
- },
835
- ): Promise<boolean> {
836
- const template = getBuiltinAiSkillTemplates().find((entry) => entry.key === input.templateId);
837
- if (!template) throw new AiSkillInputError("Unknown Skill template.");
838
- if (template.version !== input.templateVersion) throw new AiSkillRevisionConflictError();
839
- const { hash } = validateTemplate(template);
840
- return sql.begin(async (tx) => {
841
- await tx`INSERT INTO ai.skill_seeds (key, catalog_version) VALUES (${template.key}, ${template.version})
842
- ON CONFLICT (key) DO NOTHING`;
843
- const [seed] = await tx<{ skill_id: string | null; catalog_version: number }[]>`
844
- SELECT skill_id, catalog_version FROM ai.skill_seeds WHERE key = ${template.key} FOR UPDATE`;
845
- const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
846
- if (!row) return false;
847
- if (
848
- Number(row.revision) !== input.expectedRevision ||
849
- seed!.catalog_version > template.version ||
850
- Number(row.template_version) > template.version
851
- )
852
- throw new AiSkillRevisionConflictError();
853
- if (seed!.skill_id && seed!.skill_id !== row.id)
854
- throw new AiSkillInputError("This template is already linked, possibly to a deleted Skill.");
855
- if (input.mode === "associate") {
856
- if (row.template_id) throw new AiSkillInputError("This Skill already has a template.");
857
- await tx`UPDATE ai.skills SET template_id = ${template.key}, template_version = ${template.version},
858
- template_hash = ${hash}, managed_key = NULL, revision = revision + 1, updated_at = now() WHERE id = ${row.id}::uuid`;
859
- } else {
860
- if (row.template_id !== template.key) throw new AiSkillInputError("Associate this Skill with its template first.");
861
- await replaceTemplate(row, template, tx);
862
- }
863
- await tx`UPDATE ai.skill_seeds SET skill_id = ${row.id}::uuid, catalog_version = ${template.version} WHERE key = ${template.key}`;
864
- return true;
865
- });
866
- },
867
-
868
830
  async list(params: {
869
831
  search?: string;
870
832
  page?: number;
@@ -883,13 +845,14 @@ export const aiSkills = {
883
845
  OR lower(skill.short_id) LIKE ${pattern} ESCAPE '\\'
884
846
  `;
885
847
  const rows = await sql<AdminSkillRow[]>`
886
- SELECT skill.*,
848
+ SELECT skill.*, app_skill.app_id, app_skill.app_name, app_skill.source_hash, app_skill.applied_hash, app_skill.available,
887
849
  (SELECT COALESCE(jsonb_agg(jsonb_build_object('path', content.path, 'content', content.content)), '[]'::jsonb)
888
850
  FROM ai.skill_references content WHERE content.skill_id = skill.id) AS content_references,
889
851
  count(DISTINCT reference.path)::int AS reference_count,
890
852
  count(DISTINCT skill_access.access_id)::int AS access_count,
891
853
  count(DISTINCT skill_access.access_id) FILTER (WHERE access.permission = 'admin')::int AS admin_count
892
854
  FROM ai.skills skill
855
+ LEFT JOIN ai.app_skills app_skill ON app_skill.skill_id=skill.id
893
856
  LEFT JOIN ai.skill_references reference ON reference.skill_id = skill.id
894
857
  LEFT JOIN ai.skill_access skill_access ON skill_access.skill_id = skill.id
895
858
  LEFT JOIN auth.access access ON access.id = skill_access.access_id
@@ -897,7 +860,7 @@ export const aiSkills = {
897
860
  OR lower(skill.name) LIKE ${pattern} ESCAPE '\\'
898
861
  OR lower(skill.description) LIKE ${pattern} ESCAPE '\\'
899
862
  OR lower(skill.short_id) LIKE ${pattern} ESCAPE '\\'
900
- GROUP BY skill.id
863
+ GROUP BY skill.id, app_skill.key
901
864
  ORDER BY skill.updated_at DESC, skill.id
902
865
  LIMIT ${perPage} OFFSET ${offset}
903
866
  `;
@@ -921,7 +884,7 @@ export const aiSkills = {
921
884
  GROUP BY skill.id
922
885
  )
923
886
  SELECT count(*)::int AS total,
924
- count(*) FILTER (WHERE admin_count = 0)::int AS unmanaged,
887
+ count(*) FILTER (WHERE admin_count = 0 AND NOT EXISTS (SELECT 1 FROM ai.app_skills WHERE skill_id=filtered.id))::int AS unmanaged,
925
888
  coalesce(sum(access_count), 0)::int AS total_access
926
889
  FROM filtered
927
890
  `;
@@ -930,18 +893,19 @@ export const aiSkills = {
930
893
 
931
894
  async getByShortId(shortId: string): Promise<AiSkillAdminListItem | null> {
932
895
  const rows = await sql<AdminSkillRow[]>`
933
- SELECT skill.*,
896
+ SELECT skill.*, app_skill.app_id, app_skill.app_name, app_skill.source_hash, app_skill.applied_hash, app_skill.available,
934
897
  (SELECT COALESCE(jsonb_agg(jsonb_build_object('path', content.path, 'content', content.content)), '[]'::jsonb)
935
898
  FROM ai.skill_references content WHERE content.skill_id = skill.id) AS content_references,
936
899
  count(DISTINCT reference.path)::int AS reference_count,
937
900
  count(DISTINCT skill_access.access_id)::int AS access_count,
938
901
  count(DISTINCT skill_access.access_id) FILTER (WHERE access.permission = 'admin')::int AS admin_count
939
902
  FROM ai.skills skill
903
+ LEFT JOIN ai.app_skills app_skill ON app_skill.skill_id=skill.id
940
904
  LEFT JOIN ai.skill_references reference ON reference.skill_id = skill.id
941
905
  LEFT JOIN ai.skill_access skill_access ON skill_access.skill_id = skill.id
942
906
  LEFT JOIN auth.access access ON access.id = skill_access.access_id
943
907
  WHERE skill.short_id = ${shortId}
944
- GROUP BY skill.id
908
+ GROUP BY skill.id, app_skill.key
945
909
  `;
946
910
  return rows[0] ? toAdminSkill(rows[0]) : null;
947
911
  },
@@ -988,8 +952,9 @@ export const aiSkills = {
988
952
 
989
953
  async setEnabled(skillId: string, subject: AccessSubject | null, enabled: boolean): Promise<boolean | null> {
990
954
  if (subject?.type !== "user") return null;
955
+ const roles = await subjectRoles(subject);
991
956
  return sql.begin(async (tx) => {
992
- if (!(await requireSkill(await getRow(skillId, tx), subject, "read", tx))) return null;
957
+ if (!(await requireSkill(await getRow(skillId, tx), subject, "read", tx, roles))) return null;
993
958
  if (enabled) {
994
959
  await tx`
995
960
  DELETE FROM ai.skill_user_disabled
@@ -1024,9 +989,10 @@ export const aiSkills = {
1024
989
  ): Promise<AiSkill | null> {
1025
990
  if (input.references.length === 0) throw new AiSkillInputError("Set at least one Skill reference.");
1026
991
  const references = validateAiSkillReferences(input.references);
992
+ const roles = await subjectRoles(subject);
1027
993
  return sql.begin(async (tx) => {
1028
994
  const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
1029
- if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "write")) return null;
995
+ if (!row || !(await canMutate(row.id, subject, "write", tx, roles))) return null;
1030
996
  if (Number(row.revision) !== input.expectedRevision) throw new AiSkillRevisionConflictError();
1031
997
  const existing = await tx<AiSkillReferenceInput[]>`
1032
998
  SELECT path, content FROM ai.skill_references WHERE skill_id = ${skillId}::uuid
@@ -1045,7 +1011,7 @@ export const aiSkills = {
1045
1011
  UPDATE ai.skills SET revision = revision + 1, updated_at = now()
1046
1012
  WHERE id = ${skillId}::uuid RETURNING *
1047
1013
  `;
1048
- return toSkill(updated!, subject, tx);
1014
+ return toSkill(updated!, subject, tx, roles);
1049
1015
  });
1050
1016
  },
1051
1017
 
@@ -1055,9 +1021,10 @@ export const aiSkills = {
1055
1021
  input: { expectedRevision: number; path: string },
1056
1022
  ): Promise<AiSkill | null> {
1057
1023
  const [reference] = validateAiSkillReferences([{ path: input.path, content: "" }]);
1024
+ const roles = await subjectRoles(subject);
1058
1025
  return sql.begin(async (tx) => {
1059
1026
  const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
1060
- if (!row || !hasPermission(await permissionFor(row.id, subject, tx), "write")) return null;
1027
+ if (!row || !(await canMutate(row.id, subject, "write", tx, roles))) return null;
1061
1028
  if (Number(row.revision) !== input.expectedRevision) throw new AiSkillRevisionConflictError();
1062
1029
  const deleted = await tx`
1063
1030
  DELETE FROM ai.skill_references
@@ -1068,7 +1035,7 @@ export const aiSkills = {
1068
1035
  UPDATE ai.skills SET revision = revision + 1, updated_at = now()
1069
1036
  WHERE id = ${skillId}::uuid RETURNING *
1070
1037
  `;
1071
- return toSkill(updated!, subject, tx);
1038
+ return toSkill(updated!, subject, tx, roles);
1072
1039
  });
1073
1040
  },
1074
1041
 
@@ -1080,11 +1047,12 @@ export const aiSkills = {
1080
1047
  const name = typeof selector === "string" ? validateAiSkillName(selector) : null;
1081
1048
  const shortId = typeof selector === "string" ? null : selector.id;
1082
1049
  if (shortId !== null && !AI_SHORT_ID_PATTERN.test(shortId)) throw new AiSkillInputError("Invalid Skill ID.");
1050
+ const roles = await subjectRoles(subject);
1083
1051
  return sql.begin(async (tx) => {
1084
1052
  const [row] = await tx<SkillRow[]>`
1085
1053
  SELECT * FROM ai.skills WHERE name = ${name} OR short_id = ${shortId} FOR SHARE
1086
1054
  `;
1087
- const skill = await requireSkill(row ?? null, subject, "read", tx);
1055
+ const skill = await requireSkill(row ?? null, subject, "read", tx, roles);
1088
1056
  if (!skill?.enabled) return null;
1089
1057
  const files = skillSnapshotFiles(skill);
1090
1058
  await tx`
@@ -1114,30 +1082,12 @@ export const aiSkills = {
1114
1082
  },
1115
1083
 
1116
1084
  async listTurnFiles(turnId: string, subject: AccessSubject | null): Promise<AiSkillFileToolStat[]> {
1117
- const userId = subject?.type === "user" ? subject.userId : null;
1118
- const rows = await sql<SnapshotRow[]>`
1119
- SELECT DISTINCT snapshot.skill_name, snapshot.description, snapshot.revision, snapshot.instructions,
1120
- snapshot.files, snapshot.loaded_at
1121
- FROM ai.turn_skill_snapshots snapshot
1122
- JOIN LATERAL (${effectivePermission(sql`snapshot.skill_id`, subject)}) access ON true
1123
- LEFT JOIN ai.skill_user_disabled disabled
1124
- ON disabled.skill_id = snapshot.skill_id AND disabled.user_id = ${userId}::uuid
1125
- WHERE snapshot.turn_id = ${turnId}::uuid AND access.permission <> 'none'
1126
- AND disabled.user_id IS NULL
1127
- ORDER BY snapshot.skill_name
1128
- `;
1129
- return rows.flatMap((row) =>
1130
- snapshotFiles(row.files).map((file) => ({
1131
- path: file.path,
1132
- mediaType: "text/markdown",
1133
- size: new TextEncoder().encode(file.content).byteLength,
1134
- updatedAt: iso(row.loaded_at),
1135
- })),
1136
- );
1085
+ return listSkillTurnFiles(turnId, subject, await subjectRoles(subject));
1137
1086
  },
1138
1087
 
1139
1088
  async readTurnFile(turnId: string, path: string, subject: AccessSubject | null): Promise<AiSkillFileToolContent | null> {
1140
- const files = await this.listTurnFiles(turnId, subject);
1089
+ const roles = await subjectRoles(subject);
1090
+ const files = await listSkillTurnFiles(turnId, subject, roles);
1141
1091
  const stat = files.find((file) => file.path === path);
1142
1092
  if (!stat) return null;
1143
1093
  const [skillName] = path.split("/", 1);
@@ -1146,7 +1096,7 @@ export const aiSkills = {
1146
1096
  SELECT DISTINCT snapshot.skill_name, snapshot.description, snapshot.revision, snapshot.instructions,
1147
1097
  snapshot.files, snapshot.loaded_at
1148
1098
  FROM ai.turn_skill_snapshots snapshot
1149
- JOIN LATERAL (${effectivePermission(sql`snapshot.skill_id`, subject)}) access ON true
1099
+ JOIN LATERAL (${effectivePermission(sql`snapshot.skill_id`, subject, roles)}) access ON true
1150
1100
  LEFT JOIN ai.skill_user_disabled disabled
1151
1101
  ON disabled.skill_id = snapshot.skill_id AND disabled.user_id = ${userId}::uuid
1152
1102
  WHERE snapshot.turn_id = ${turnId}::uuid AND snapshot.skill_name = ${skillName}
@@ -1157,7 +1107,3 @@ export const aiSkills = {
1157
1107
  return file ? { ...stat, bytes: new TextEncoder().encode(file.content) } : null;
1158
1108
  },
1159
1109
  };
1160
-
1161
- export const seedCloudAiSkills = async (): Promise<void> => {
1162
- for (const seed of getBuiltinAiSkillTemplates()) await aiSkills.seedOnce(seed);
1163
- };