@k2b/cloud 0.44.0 → 0.45.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 (73) hide show
  1. package/package.json +6 -2
  2. package/src/access/GroupCoverage.tsx +20 -5
  3. package/src/access/PermissionEditor.tsx +11 -2
  4. package/src/access/PrincipalAvatar.tsx +30 -21
  5. package/src/access/messages.ts +4 -4
  6. package/src/account/EntitySearch.tsx +12 -0
  7. package/src/ai/dictation-runtime.ts +6 -2
  8. package/src/ai/executor.ts +9 -3
  9. package/src/ai/index.ts +1 -0
  10. package/src/ai/project-tool.ts +4 -4
  11. package/src/ai/projects-authorization.ts +53 -0
  12. package/src/ai/projects-routes.ts +56 -46
  13. package/src/ai/projects.ts +100 -79
  14. package/src/ai/recent-resources.ts +3 -3
  15. package/src/ai/routes.ts +30 -30
  16. package/src/ai/skill-tool.ts +4 -4
  17. package/src/ai/skills-routes.ts +27 -26
  18. package/src/ai/skills.ts +120 -81
  19. package/src/api/accounts-entities.ts +6 -3
  20. package/src/api/admin-ai-skills.ts +4 -1
  21. package/src/api/auth.ts +9 -2
  22. package/src/browser/session.ts +39 -0
  23. package/src/browser/shared-text-history-validation.ts +151 -0
  24. package/src/browser/shared-text-history.ts +464 -0
  25. package/src/browser/shared-text-offline-authorized.ts +29 -0
  26. package/src/browser/shared-text-offline.ts +351 -0
  27. package/src/browser/shared-text-provider.ts +714 -0
  28. package/src/browser/shared-text-table-contract.ts +150 -0
  29. package/src/browser/shared-text.ts +19 -0
  30. package/src/contracts/shared.ts +2 -1
  31. package/src/server/api/respond.ts +10 -2
  32. package/src/server/index.ts +1 -0
  33. package/src/server/services/access.ts +23 -10
  34. package/src/server/services/index.ts +1 -0
  35. package/src/services/accounts/app.ts +30 -13
  36. package/src/services/accounts/entities.ts +29 -16
  37. package/src/services/accounts/group-sql.ts +23 -0
  38. package/src/services/accounts/groups.ts +16 -13
  39. package/src/services/accounts/identities.ts +8 -1
  40. package/src/services/accounts/identity-reconciliation.ts +3 -1
  41. package/src/services/accounts/local-groups.ts +69 -4
  42. package/src/services/accounts/users.ts +38 -8
  43. package/src/services/mandates/index.ts +2 -2
  44. package/src/services/mandates/policy.ts +1 -0
  45. package/src/services/session/index.ts +12 -3
  46. package/src/shared/email-html.ts +1 -0
  47. package/src/shared/markdown/extensions/katex.ts +8 -1
  48. package/src/shared/markdown/extensions/tables.ts +14 -1
  49. package/src/shared/markdown/index.ts +22 -11
  50. package/src/shared-text/anchors.ts +29 -0
  51. package/src/shared-text/awareness.ts +87 -0
  52. package/src/shared-text/changes.ts +27 -0
  53. package/src/shared-text/documents.ts +428 -77
  54. package/src/shared-text/events.ts +12 -2
  55. package/src/shared-text/index.ts +16 -1
  56. package/src/shared-text/log.ts +24 -5
  57. package/src/shared-text/snapshots.ts +69 -23
  58. package/src/shared-text/structural-history.ts +377 -0
  59. package/src/shared-text/table-actions.ts +432 -0
  60. package/src/shared-text/types.ts +24 -5
  61. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  62. package/src/ssr/CloudShell.island.tsx +19 -3
  63. package/src/ssr/Layout.tsx +1 -1
  64. package/src/ssr/MobileProfileActions.tsx +2 -2
  65. package/src/ssr/ProfilePreferences.tsx +2 -1
  66. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  67. package/src/ssr/admin-navigation.ts +1 -11
  68. package/src/ssr/app-launchpad.tsx +6 -1
  69. package/src/ssr/platform-messages.ts +2 -0
  70. package/src/ssr/profile-actions.ts +3 -6
  71. package/src/ssr/shell-data.tsx +1 -1
  72. package/src/workflows/language/expressions.ts +3 -2
  73. package/src/workflows/runtime/executor.ts +2 -1
@@ -1,11 +1,11 @@
1
1
  import { type SQL, type SQLQuery, sql } from "bun";
2
2
  import type { CloudResourceRef } from "../contracts/capabilities";
3
3
  import { AuthenticatedPrincipalSchema, type ServiceAccountKind } from "../contracts/shared";
4
- import type { AccessSubject } from "../server";
5
4
  import {
6
5
  buildAccessPrincipalCondition,
7
6
  createAccess,
8
7
  deleteAccess,
8
+ ensureManagerRemains,
9
9
  hasPermission,
10
10
  type PermissionLevel,
11
11
  type Principal,
@@ -17,6 +17,13 @@ import { AiFileVersionConflict, AiFileWriteError, aiFileContentVersion } from ".
17
17
  import { isAiImage } from "./file-media-type";
18
18
  import { mountAiProjectFilePath } from "./file-mount";
19
19
  import { pickStoredAiFilePath } from "./files-store";
20
+ import {
21
+ type AiResourceContext,
22
+ aiResourceAccessSubject,
23
+ aiResourcePermissionCap,
24
+ capAiResourcePermission,
25
+ requireAiResourceCreation,
26
+ } from "./projects-authorization";
20
27
  import { withAiShortIdForDb } from "./short-id";
21
28
  import type { AiProjectPromptSnapshot } from "./types";
22
29
 
@@ -86,7 +93,7 @@ export type AiProjectAccess = {
86
93
  permission: AiProjectPermission;
87
94
  displayName?: string;
88
95
  avatarHash?: string | null;
89
- /** Kind of a `service_account` principal; presentation only. */
96
+ /** Kind of a `service_account` principal, including manager eligibility. */
90
97
  serviceAccountKind?: ServiceAccountKind;
91
98
  createdAt: string;
92
99
  };
@@ -171,15 +178,21 @@ type AdminProjectRow = ProjectRow & {
171
178
  };
172
179
 
173
180
  const iso = (value: Date | string): string => (value instanceof Date ? value.toISOString() : new Date(value).toISOString());
174
- const actorUserId = (subject: AccessSubject | null): string | null => (subject?.type === "user" ? subject.userId : null);
181
+ const actorUserId = (input: AiResourceContext | null): string | null => {
182
+ const subject = aiResourceAccessSubject(input);
183
+ return subject?.type === "user" ? subject.userId : null;
184
+ };
175
185
 
176
- const principalForSubject = (subject: AccessSubject): Principal =>
177
- subject.type === "user"
186
+ const principalForSubject = (input: AiResourceContext): Principal => {
187
+ const subject = aiResourceAccessSubject(input)!;
188
+ return subject.type === "user"
178
189
  ? { type: "user", userId: subject.userId }
179
190
  : { type: "service_account", serviceAccountId: subject.serviceAccountId };
191
+ };
180
192
 
181
- const accessMatch = (subject: AccessSubject | null): SQLQuery =>
182
- subject === null
193
+ const accessMatch = (input: AiResourceContext | null): SQLQuery => {
194
+ const subject = aiResourceAccessSubject(input);
195
+ return subject === null || aiResourcePermissionCap(input) === "none"
183
196
  ? sql`FALSE`
184
197
  : buildAccessPrincipalCondition({
185
198
  subject,
@@ -190,8 +203,9 @@ const accessMatch = (subject: AccessSubject | null): SQLQuery =>
190
203
  authenticatedOnly: sql`access.authenticated_only`,
191
204
  },
192
205
  });
206
+ };
193
207
 
194
- const permissionFor = async (row: ProjectRow, subject: AccessSubject | null, db: SQL = sql): Promise<AiProjectPermission | "none"> => {
208
+ const permissionFor = async (row: ProjectRow, subject: AiResourceContext | null, db: SQL = sql): Promise<AiProjectPermission | "none"> => {
195
209
  const match = accessMatch(subject);
196
210
  const rows = await db<{ permission: AiProjectPermission }[]>`
197
211
  SELECT access.permission
@@ -201,10 +215,10 @@ const permissionFor = async (row: ProjectRow, subject: AccessSubject | null, db:
201
215
  ORDER BY CASE access.permission WHEN 'admin' THEN 3 WHEN 'write' THEN 2 WHEN 'read' THEN 1 ELSE 0 END DESC
202
216
  LIMIT 1
203
217
  `;
204
- return rows[0]?.permission ?? "none";
218
+ return capAiResourcePermission(rows[0]?.permission ?? "none", subject);
205
219
  };
206
220
 
207
- const toProject = async (row: ProjectRow, subject: AccessSubject | null, db: SQL = sql): Promise<AiProject | null> => {
221
+ const toProject = async (row: ProjectRow, subject: AiResourceContext | null, db: SQL = sql): Promise<AiProject | null> => {
208
222
  const permission = await permissionFor(row, subject, db);
209
223
  if (permission === "none") return null;
210
224
  return {
@@ -331,41 +345,34 @@ const createProjectAccess = async (
331
345
  };
332
346
 
333
347
  const updateProjectAccess = async (projectId: string, accessId: string, permission: AiProjectPermission, db: SQL): Promise<boolean> => {
334
- const [bound] = await db<{ id: string; permission: AiProjectPermission }[]>`
335
- SELECT access.id, access.permission
348
+ const [bound] = await db<{ id: string }[]>`
349
+ SELECT access.id
336
350
  FROM ai.project_access project_access
337
351
  JOIN auth.access access ON access.id = project_access.access_id
338
352
  WHERE project_access.project_id = ${projectId}::uuid AND project_access.short_id = ${accessId}
339
353
  `;
340
354
  if (!bound) return false;
341
- if (bound.permission === "admin" && permission !== "admin") {
342
- const [admins] = await db<{ count: number }[]>`
343
- SELECT count(*)::int AS count FROM ai.project_access project_access
344
- JOIN auth.access access ON access.id = project_access.access_id
345
- WHERE project_access.project_id = ${projectId}::uuid AND access.permission = 'admin'
346
- `;
347
- if ((admins?.count ?? 0) <= 1) throw new AiProjectLastAdminError();
348
- }
355
+ const before = await listProjectAccess(projectId, db);
356
+ const guarded = ensureManagerRemains({
357
+ before,
358
+ after: before.map((entry) => (entry.shortId === accessId ? { ...entry, permission } : entry)),
359
+ });
360
+ if (!guarded.ok) throw new AiProjectLastAdminError();
349
361
  const result = await db`UPDATE auth.access SET permission = ${permission}::auth.permission_level WHERE id = ${bound.id}::uuid`;
350
362
  return result.count === 1;
351
363
  };
352
364
 
353
365
  const revokeProjectAccess = async (projectId: string, accessId: string, db: SQL): Promise<boolean> => {
354
- const [bound] = await db<{ access_id: string; permission: AiProjectPermission }[]>`
355
- SELECT project_access.access_id, access.permission
366
+ const [bound] = await db<{ access_id: string }[]>`
367
+ SELECT project_access.access_id
356
368
  FROM ai.project_access project_access
357
369
  JOIN auth.access access ON access.id = project_access.access_id
358
370
  WHERE project_access.project_id = ${projectId}::uuid AND project_access.short_id = ${accessId}
359
371
  `;
360
372
  if (!bound) return false;
361
- if (bound.permission === "admin") {
362
- const [admins] = await db<{ count: number }[]>`
363
- SELECT count(*)::int AS count FROM ai.project_access project_access
364
- JOIN auth.access access ON access.id = project_access.access_id
365
- WHERE project_access.project_id = ${projectId}::uuid AND access.permission = 'admin'
366
- `;
367
- if ((admins?.count ?? 0) <= 1) throw new AiProjectLastAdminError();
368
- }
373
+ const before = await listProjectAccess(projectId, db);
374
+ const guarded = ensureManagerRemains({ before, after: before.filter((entry) => entry.shortId !== accessId) });
375
+ if (!guarded.ok) throw new AiProjectLastAdminError();
369
376
  const deleted = await deleteAccess({ id: bound.access_id }, db);
370
377
  if (!deleted.ok) throw new Error(deleted.error.message);
371
378
  return true;
@@ -373,7 +380,7 @@ const revokeProjectAccess = async (projectId: string, accessId: string, db: SQL)
373
380
 
374
381
  const requireProject = async (
375
382
  projectId: string,
376
- subject: AccessSubject | null,
383
+ subject: AiResourceContext | null,
377
384
  permission: AiProjectPermission = "read",
378
385
  ): Promise<AiProject | null> => {
379
386
  const row = await getRow(projectId);
@@ -384,7 +391,7 @@ const requireProject = async (
384
391
 
385
392
  const requireProjectByShortId = async (
386
393
  shortId: string,
387
- subject: AccessSubject | null,
394
+ subject: AiResourceContext | null,
388
395
  permission: AiProjectPermission = "read",
389
396
  ): Promise<AiProject | null> => {
390
397
  const row = await getRowByShortId(shortId);
@@ -413,13 +420,14 @@ const projectSearchPattern = (value: string | undefined): string | null => {
413
420
 
414
421
  export const aiProjects = {
415
422
  async create(input: {
416
- subject: AccessSubject;
423
+ subject: AiResourceContext;
417
424
  name: string;
418
425
  description?: string;
419
426
  icon?: string;
420
427
  instructions?: string;
421
428
  defaultModel?: AiModelRequest | null;
422
429
  }): Promise<AiProject> {
430
+ requireAiResourceCreation(input.subject);
423
431
  return sql.begin(async (tx) => {
424
432
  const rows = await withAiShortIdForDb(
425
433
  tx,
@@ -443,11 +451,11 @@ export const aiProjects = {
443
451
  VALUES (${rows[0]!.id}::uuid, ${created.data.id}::uuid, ${shortId})
444
452
  `,
445
453
  );
446
- return { ...(await toProject(rows[0]!, input.subject, tx))!, permission: "admin" };
454
+ return (await toProject(rows[0]!, input.subject, tx))!;
447
455
  });
448
456
  },
449
457
 
450
- async list(subject: AccessSubject | null): Promise<AiProject[]> {
458
+ async list(subject: AiResourceContext | null): Promise<AiProject[]> {
451
459
  const match = accessMatch(subject);
452
460
  const rows = await sql<ProjectRow[]>`
453
461
  SELECT DISTINCT project.*, lower(project.name) AS sort_name
@@ -460,7 +468,7 @@ export const aiProjects = {
460
468
  return (await Promise.all(rows.map((row) => toProject(row, subject)))).filter((project): project is AiProject => Boolean(project));
461
469
  },
462
470
 
463
- async resolveShortIds(projectIds: readonly string[], subject: AccessSubject | null): Promise<Map<string, string>> {
471
+ async resolveShortIds(projectIds: readonly string[], subject: AiResourceContext | null): Promise<Map<string, string>> {
464
472
  const ids = [...new Set(projectIds)];
465
473
  if (!ids.length) return new Map();
466
474
  const match = accessMatch(subject);
@@ -475,7 +483,7 @@ export const aiProjects = {
475
483
  return new Map(rows.map((row) => [row.id, row.short_id]));
476
484
  },
477
485
 
478
- async scopeVersion(subject: AccessSubject | null): Promise<string> {
486
+ async scopeVersion(subject: AiResourceContext | null): Promise<string> {
479
487
  const projects = await this.list(subject);
480
488
  return projects
481
489
  .map((project) => `${project.shortId}:${project.permission}`)
@@ -488,30 +496,34 @@ export const aiProjects = {
488
496
 
489
497
  async update(
490
498
  projectId: string,
491
- subject: AccessSubject | null,
499
+ subject: AiResourceContext | null,
492
500
  input: Partial<Pick<AiProject, "name" | "description" | "icon" | "instructions" | "defaultModel">>,
493
501
  ): Promise<AiProject | null> {
494
502
  const changesTrustedInstructions = Object.hasOwn(input, "instructions") || Object.hasOwn(input, "defaultModel");
495
- if (!(await requireProject(projectId, subject, changesTrustedInstructions ? "admin" : "write"))) return null;
496
- const rows = await sql<ProjectRow[]>`
497
- UPDATE ai.projects
498
- SET name = COALESCE(${input.name?.trim() ?? null}, name),
499
- description = COALESCE(${input.description?.trim() ?? null}, description),
500
- icon = COALESCE(${input.icon?.trim() ?? null}, icon),
501
- instructions = COALESCE(${input.instructions?.trim() ?? null}, instructions),
502
- default_model = CASE
503
- WHEN ${Object.hasOwn(input, "defaultModel")} THEN ${input.defaultModel ? JSON.stringify(AiModelRequestSchema.parse(input.defaultModel)) : null}::text::jsonb
504
- ELSE default_model
505
- END,
506
- revision = revision + 1,
507
- updated_at = now()
508
- WHERE id = ${projectId}::uuid
509
- RETURNING *
510
- `;
511
- return rows[0] ? toProject(rows[0], subject) : null;
503
+ return sql.begin(async (tx) => {
504
+ const [project] = await tx<ProjectRow[]>`SELECT * FROM ai.projects WHERE id = ${projectId}::uuid FOR UPDATE`;
505
+ if (!project || !hasPermission(await permissionFor(project, subject, tx), changesTrustedInstructions ? "admin" : "write"))
506
+ return null;
507
+ const rows = await tx<ProjectRow[]>`
508
+ UPDATE ai.projects
509
+ SET name = COALESCE(${input.name?.trim() ?? null}, name),
510
+ description = COALESCE(${input.description?.trim() ?? null}, description),
511
+ icon = COALESCE(${input.icon?.trim() ?? null}, icon),
512
+ instructions = COALESCE(${input.instructions?.trim() ?? null}, instructions),
513
+ default_model = CASE
514
+ WHEN ${Object.hasOwn(input, "defaultModel")} THEN ${input.defaultModel ? JSON.stringify(AiModelRequestSchema.parse(input.defaultModel)) : null}::text::jsonb
515
+ ELSE default_model
516
+ END,
517
+ revision = revision + 1,
518
+ updated_at = now()
519
+ WHERE id = ${projectId}::uuid
520
+ RETURNING *
521
+ `;
522
+ return rows[0] ? toProject(rows[0], subject, tx) : null;
523
+ });
512
524
  },
513
525
 
514
- async delete(projectId: string, subject: AccessSubject | null): Promise<boolean> {
526
+ async delete(projectId: string, subject: AiResourceContext | null): Promise<boolean> {
515
527
  return sql.begin(async (tx) => {
516
528
  const [project] = await tx<ProjectRow[]>`
517
529
  SELECT * FROM ai.projects WHERE id = ${projectId}::uuid FOR UPDATE
@@ -526,7 +538,7 @@ export const aiProjects = {
526
538
  });
527
539
  },
528
540
 
529
- async snapshot(projectId: string, subject: AccessSubject | null): Promise<AiProjectPromptSnapshot | null> {
541
+ async snapshot(projectId: string, subject: AiResourceContext | null): Promise<AiProjectPromptSnapshot | null> {
530
542
  return sql.begin(async (tx) => {
531
543
  const [row] = await tx<ProjectRow[]>`SELECT * FROM ai.projects WHERE id = ${projectId}::uuid FOR SHARE`;
532
544
  if (!row || !hasPermission(await permissionFor(row, subject, tx), "read")) return null;
@@ -569,14 +581,14 @@ export const aiProjects = {
569
581
  });
570
582
  },
571
583
 
572
- async listAccess(projectId: string, subject: AccessSubject | null): Promise<AiProjectAccess[] | null> {
584
+ async listAccess(projectId: string, subject: AiResourceContext | null): Promise<AiProjectAccess[] | null> {
573
585
  if (!(await requireProject(projectId, subject, "admin"))) return null;
574
586
  return listProjectAccess(projectId);
575
587
  },
576
588
 
577
589
  async grantAccess(
578
590
  projectId: string,
579
- subject: AccessSubject | null,
591
+ subject: AiResourceContext | null,
580
592
  input: { principal: Principal; permission: AiProjectPermission },
581
593
  ): Promise<AiProjectAccess | null> {
582
594
  return sql.begin(async (tx) => {
@@ -591,7 +603,7 @@ export const aiProjects = {
591
603
  async updateAccess(
592
604
  projectId: string,
593
605
  accessId: string,
594
- subject: AccessSubject | null,
606
+ subject: AiResourceContext | null,
595
607
  permission: AiProjectPermission,
596
608
  ): Promise<boolean> {
597
609
  return sql.begin(async (tx) => {
@@ -603,7 +615,7 @@ export const aiProjects = {
603
615
  });
604
616
  },
605
617
 
606
- async revokeAccess(projectId: string, accessId: string, subject: AccessSubject | null): Promise<boolean> {
618
+ async revokeAccess(projectId: string, accessId: string, subject: AiResourceContext | null): Promise<boolean> {
607
619
  return sql.begin(async (tx) => {
608
620
  const [project] = await tx<ProjectRow[]>`
609
621
  SELECT * FROM ai.projects WHERE id = ${projectId}::uuid FOR UPDATE
@@ -727,7 +739,7 @@ export const aiProjects = {
727
739
  },
728
740
  },
729
741
 
730
- async listKnowledge(projectId: string, subject: AccessSubject | null, query?: string): Promise<AiProjectKnowledge[]> {
742
+ async listKnowledge(projectId: string, subject: AiResourceContext | null, query?: string): Promise<AiProjectKnowledge[]> {
731
743
  if (!(await requireProject(projectId, subject, "read"))) return [];
732
744
  const q = query?.trim() || null;
733
745
  const pattern = q ? `%${q.toLowerCase().replace(/[\\%_]/g, (char) => `\\${char}`)}%` : null;
@@ -742,7 +754,7 @@ export const aiProjects = {
742
754
  return rows.map(toKnowledge);
743
755
  },
744
756
 
745
- async getKnowledgeByShortId(projectId: string, shortId: string, subject: AccessSubject | null): Promise<AiProjectKnowledge | null> {
757
+ async getKnowledgeByShortId(projectId: string, shortId: string, subject: AiResourceContext | null): Promise<AiProjectKnowledge | null> {
746
758
  if (!(await requireProject(projectId, subject, "read"))) return null;
747
759
  const rows = await sql<KnowledgeRow[]>`
748
760
  SELECT * FROM ai.project_knowledge
@@ -754,11 +766,12 @@ export const aiProjects = {
754
766
 
755
767
  async createKnowledge(
756
768
  projectId: string,
757
- subject: AccessSubject | null,
769
+ subject: AiResourceContext | null,
758
770
  input: { title: string; content: string },
759
771
  ): Promise<AiProjectKnowledge | null> {
760
- if (!(await requireProject(projectId, subject, "write"))) return null;
761
772
  return sql.begin(async (tx) => {
773
+ const [project] = await tx<ProjectRow[]>`SELECT * FROM ai.projects WHERE id = ${projectId}::uuid FOR UPDATE`;
774
+ if (!project || !hasPermission(await permissionFor(project, subject, tx), "write")) return null;
762
775
  const rows = await withAiShortIdForDb(
763
776
  tx,
764
777
  "idx_ai_project_knowledge_short_id",
@@ -776,11 +789,12 @@ export const aiProjects = {
776
789
  async updateKnowledge(
777
790
  projectId: string,
778
791
  knowledgeId: string,
779
- subject: AccessSubject | null,
792
+ subject: AiResourceContext | null,
780
793
  input: { title?: string; content?: string },
781
794
  ): Promise<AiProjectKnowledge | null> {
782
- if (!(await requireProject(projectId, subject, "write"))) return null;
783
795
  return sql.begin(async (tx) => {
796
+ const [project] = await tx<ProjectRow[]>`SELECT * FROM ai.projects WHERE id = ${projectId}::uuid FOR UPDATE`;
797
+ if (!project || !hasPermission(await permissionFor(project, subject, tx), "write")) return null;
784
798
  const rows = await tx<KnowledgeRow[]>`
785
799
  UPDATE ai.project_knowledge
786
800
  SET title = COALESCE(${input.title?.trim() ?? null}, title), content = COALESCE(${input.content?.trim() ?? null}, content), updated_at = now()
@@ -792,9 +806,10 @@ export const aiProjects = {
792
806
  });
793
807
  },
794
808
 
795
- async deleteKnowledge(projectId: string, knowledgeId: string, subject: AccessSubject | null): Promise<boolean> {
796
- if (!(await requireProject(projectId, subject, "write"))) return false;
809
+ async deleteKnowledge(projectId: string, knowledgeId: string, subject: AiResourceContext | null): Promise<boolean> {
797
810
  return sql.begin(async (tx) => {
811
+ const [project] = await tx<ProjectRow[]>`SELECT * FROM ai.projects WHERE id = ${projectId}::uuid FOR UPDATE`;
812
+ if (!project || !hasPermission(await permissionFor(project, subject, tx), "write")) return false;
798
813
  const rows = await tx<{ id: string }[]>`
799
814
  DELETE FROM ai.project_knowledge WHERE short_id = ${knowledgeId} AND project_id = ${projectId}::uuid RETURNING id
800
815
  `;
@@ -803,7 +818,11 @@ export const aiProjects = {
803
818
  });
804
819
  },
805
820
 
806
- async listFiles(projectId: string, subject: AccessSubject | null, page?: { after?: string; limit: number }): Promise<AiProjectFile[]> {
821
+ async listFiles(
822
+ projectId: string,
823
+ subject: AiResourceContext | null,
824
+ page?: { after?: string; limit: number },
825
+ ): Promise<AiProjectFile[]> {
807
826
  if (!(await requireProject(projectId, subject, "read"))) return [];
808
827
  if (page && (!Number.isInteger(page.limit) || page.limit < 1 || page.limit > 1000)) throw new Error("Invalid file page limit");
809
828
  return (
@@ -815,7 +834,7 @@ export const aiProjects = {
815
834
 
816
835
  async writeFile(
817
836
  projectId: string,
818
- subject: AccessSubject | null,
837
+ subject: AiResourceContext | null,
819
838
  input: { path: string; mediaType: string; bytes: Uint8Array; expectedVersion?: string | null },
820
839
  ): Promise<AiProjectFile | null> {
821
840
  if (!(await requireProject(projectId, subject, "write"))) return null;
@@ -854,7 +873,7 @@ export const aiProjects = {
854
873
  async readFile(
855
874
  projectId: string,
856
875
  fileId: string,
857
- subject: AccessSubject | null,
876
+ subject: AiResourceContext | null,
858
877
  ): Promise<(AiProjectFile & { bytes: Uint8Array }) | null> {
859
878
  if (!(await requireProject(projectId, subject, "read"))) return null;
860
879
  const rows = await sql<(FileRow & { bytes: Uint8Array })[]>`
@@ -867,7 +886,7 @@ export const aiProjects = {
867
886
  async readFileByPath(
868
887
  projectId: string,
869
888
  path: string,
870
- subject: AccessSubject | null,
889
+ subject: AiResourceContext | null,
871
890
  ): Promise<(AiProjectFile & { bytes: Uint8Array }) | null> {
872
891
  if (!(await requireProject(projectId, subject, "read"))) return null;
873
892
  const normalized = normalizeProjectPath(path);
@@ -884,7 +903,7 @@ export const aiProjects = {
884
903
  return rows[0] ? { ...toFile(rows[0]), bytes: rows[0].bytes } : null;
885
904
  },
886
905
 
887
- async deleteFile(projectId: string, fileId: string, subject: AccessSubject | null): Promise<boolean> {
906
+ async deleteFile(projectId: string, fileId: string, subject: AiResourceContext | null): Promise<boolean> {
888
907
  if (!(await requireProject(projectId, subject, "write"))) return false;
889
908
  return sql.begin(async (tx) => {
890
909
  const [row] = await tx<ProjectRow[]>`SELECT * FROM ai.projects WHERE id=${projectId}::uuid FOR UPDATE`;
@@ -898,7 +917,7 @@ export const aiProjects = {
898
917
  });
899
918
  },
900
919
 
901
- async listReferences(projectId: string, subject: AccessSubject | null): Promise<AiProjectReference[]> {
920
+ async listReferences(projectId: string, subject: AiResourceContext | null): Promise<AiProjectReference[]> {
902
921
  if (!(await requireProject(projectId, subject, "read"))) return [];
903
922
  return (
904
923
  await sql<
@@ -909,11 +928,12 @@ export const aiProjects = {
909
928
 
910
929
  async createReference(
911
930
  projectId: string,
912
- subject: AccessSubject | null,
931
+ subject: AiResourceContext | null,
913
932
  input: { ref: CloudResourceRef; label?: string },
914
933
  ): Promise<AiProjectReference | null> {
915
- if (!(await requireProject(projectId, subject, "write"))) return null;
916
934
  return sql.begin(async (tx) => {
935
+ const [project] = await tx<ProjectRow[]>`SELECT * FROM ai.projects WHERE id = ${projectId}::uuid FOR UPDATE`;
936
+ if (!project || !hasPermission(await permissionFor(project, subject, tx), "write")) return null;
917
937
  const rows = await withAiShortIdForDb(
918
938
  tx,
919
939
  "idx_ai_project_resource_refs_short_id",
@@ -928,9 +948,10 @@ export const aiProjects = {
928
948
  });
929
949
  },
930
950
 
931
- async deleteReference(projectId: string, referenceId: string, subject: AccessSubject | null): Promise<boolean> {
932
- if (!(await requireProject(projectId, subject, "write"))) return false;
951
+ async deleteReference(projectId: string, referenceId: string, subject: AiResourceContext | null): Promise<boolean> {
933
952
  return sql.begin(async (tx) => {
953
+ const [project] = await tx<ProjectRow[]>`SELECT * FROM ai.projects WHERE id = ${projectId}::uuid FOR UPDATE`;
954
+ if (!project || !hasPermission(await permissionFor(project, subject, tx), "write")) return false;
934
955
  const rows = await tx<{ id: string }[]>`
935
956
  DELETE FROM ai.project_resource_refs WHERE short_id = ${referenceId} AND project_id = ${projectId}::uuid RETURNING id
936
957
  `;
@@ -123,9 +123,9 @@ export const listAiRecentResources = async (input: {
123
123
  const skillIds = candidates.flatMap(({ ref }) => (ref.type === "core.ai.skill" ? [ref.id] : []));
124
124
  // Exactly the sent Skills, so a large Skill catalog never pushes one out; failing, they are left out like a reader.
125
125
  const skills = skillIds.length
126
- ? withinBudget(signal, () =>
127
- (deps.skills ?? aiSkills).list(input.authority.accessSubject, { shortIds: skillIds, authority: input.authority }),
128
- ).catch(() => [])
126
+ ? withinBudget(signal, () => (deps.skills ?? aiSkills).list(input.authority, { shortIds: skillIds, authority: input.authority })).catch(
127
+ () => [],
128
+ )
129
129
  : Promise.resolve([]);
130
130
  const registry = candidates.some(({ ref }) => ref.type !== "core.ai.skill" && ref.type !== "assistant.artifact")
131
131
  ? withinBudget(signal, async () =>