@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
package/src/ai/skills.ts CHANGED
@@ -1,23 +1,28 @@
1
1
  import { type SQL, type SQLQuery, sql } from "bun";
2
2
  import { listApps } from "../_internal/registry";
3
3
  import { AuthenticatedPrincipalSchema, type ServiceAccountKind } from "../contracts/shared";
4
- import type { AccessSubject } from "../server";
5
4
  import type { RequestAuthority } from "../server/middleware/auth";
6
5
  import {
7
6
  buildAccessPrincipalCondition,
8
7
  createAccess,
9
8
  deleteAccess,
9
+ ensureManagerRemains,
10
10
  hasPermission,
11
11
  type PermissionLevel,
12
12
  type Principal,
13
13
  } from "../server/services/access";
14
14
  import { accessRevision } from "../server/services/access-revision";
15
- import { getRoles } from "../services/accounts/users";
16
- import { usableAppIds } from "../services/app-access";
15
+ import { evaluateAppAccess, usableAppIds } from "../services/app-access";
17
16
  import { logger } from "../services/logging";
18
17
  import { toPgTextArray, toPgUuidArray } from "../services/postgres";
19
18
  import { mountAiSkillFilePath } from "./file-mount";
20
19
  import { aiProjects } from "./projects";
20
+ import {
21
+ type AiResourceContext,
22
+ aiResourceAccessSubject,
23
+ aiResourcePermissionCap,
24
+ requireAiResourceCreation,
25
+ } from "./projects-authorization";
21
26
  import { AI_SHORT_ID_PATTERN, withAiShortIdForDb } from "./short-id";
22
27
  import { type AiSkillContent, appSkillStatus, contentHash } from "./skill-content";
23
28
  import {
@@ -67,7 +72,7 @@ export type AiSkillAccess = {
67
72
  permission: AiSkillPermission;
68
73
  displayName?: string;
69
74
  avatarHash?: string | null;
70
- /** Kind of a `service_account` principal; presentation only. */
75
+ /** Kind of a `service_account` principal, including manager eligibility. */
71
76
  serviceAccountKind?: ServiceAccountKind;
72
77
  createdAt: string;
73
78
  };
@@ -200,13 +205,21 @@ const jsonObject = (value: AiSkillExtraFrontmatter | string): AiSkillExtraFrontm
200
205
  const snapshotFiles = (value: SnapshotRow["files"]): { path: string; content: string }[] =>
201
206
  typeof value === "string" ? (JSON.parse(value) as { path: string; content: string }[]) : value;
202
207
 
203
- const principalForSubject = (subject: AccessSubject): Principal =>
204
- subject.type === "user"
208
+ const aiResourceUserId = (input: AiResourceContext | null): string | null => {
209
+ const subject = aiResourceAccessSubject(input);
210
+ return subject?.type === "user" ? subject.userId : null;
211
+ };
212
+
213
+ const principalForSubject = (input: AiResourceContext): Principal => {
214
+ const subject = aiResourceAccessSubject(input)!;
215
+ return subject.type === "user"
205
216
  ? { type: "user", userId: subject.userId }
206
217
  : { type: "service_account", serviceAccountId: subject.serviceAccountId };
218
+ };
207
219
 
208
- const accessMatch = (subject: AccessSubject | null): SQLQuery =>
209
- subject === null
220
+ const accessMatch = (input: AiResourceContext | null, audience: SkillAudience): SQLQuery => {
221
+ const subject = aiResourceAccessSubject(input);
222
+ return subject === null || (aiResourcePermissionCap(input) === "none" && audience.brokerReadAppIds.length === 0)
210
223
  ? sql`FALSE`
211
224
  : buildAccessPrincipalCondition({
212
225
  subject,
@@ -217,6 +230,7 @@ const accessMatch = (subject: AccessSubject | null): SQLQuery =>
217
230
  authenticatedOnly: sql`access.authenticated_only`,
218
231
  },
219
232
  });
233
+ };
220
234
 
221
235
  export const getRow = async (skillId: string, db: SQL = sql): Promise<SkillRow | null> =>
222
236
  (await db<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid`)[0] ?? null;
@@ -231,38 +245,64 @@ const getRowByName = async (name: string, db: SQL = sql): Promise<SkillRow | nul
231
245
  * Resolve before opening a transaction: role lookup uses its own pool connection, and a transaction that waits for a
232
246
  * second connection can exhaust the pool under concurrent requests.
233
247
  */
234
- type SkillAudience = { roles: readonly string[]; usableAppIds: readonly string[]; declaredAppIds: readonly string[] };
235
- const subjectRoles = async (subject: AccessSubject | null, authority: RequestAuthority | null = null): Promise<SkillAudience> => {
236
- const actor = authority?.actor;
237
- const roles = actor
238
- ? actor.kind === "user"
239
- ? actor.user.roles
240
- : (actor.delegatedUser?.roles ?? [])
241
- : subject?.type === "user"
242
- ? await getRoles(subject.userId)
243
- : [];
248
+ type SkillAudience = {
249
+ roles: readonly string[];
250
+ usableAppIds: readonly string[];
251
+ declaredAppIds: readonly string[];
252
+ brokerReadAppIds: readonly string[];
253
+ };
254
+ const subjectRoles = async (subject: AiResourceContext | null, authority: RequestAuthority | null = null): Promise<SkillAudience> => {
255
+ const actor = authority?.actor ?? subject?.actor;
256
+ const roles = actor?.kind === "user" ? actor.user.roles : (actor?.delegatedUser?.roles ?? []);
244
257
  const apps = await listApps().catch(() => {
245
258
  logger("ai:skills").warn("App registry unavailable; app Skills are unavailable");
246
259
  return [];
247
260
  });
261
+ const usable = [...(await usableAppIds(authority, apps))];
262
+ const resourceActor = subject?.actor;
263
+ // App Skill brokers support independent machines, including resource bindings.
264
+ // Keep identity matching and credential checks even for legacy undeclared apps.
265
+ const brokerAuthority: RequestAuthority | null =
266
+ authority &&
267
+ resourceActor?.kind === "service_account" &&
268
+ !resourceActor.delegatedUser &&
269
+ ["standalone", "agent", "resource_bound"].includes(resourceActor.serviceAccount.kind) &&
270
+ subject?.accessSubject.type === "service_account" &&
271
+ subject.accessSubject.serviceAccountId === resourceActor.serviceAccount.id
272
+ ? { ...authority, actor: resourceActor, accessSubject: subject.accessSubject, scopes: resourceActor.scopes }
273
+ : null;
248
274
  return {
249
275
  roles,
250
- usableAppIds: [...(await usableAppIds(authority, apps))],
276
+ usableAppIds: usable,
251
277
  declaredAppIds: apps.filter((app) => app.access !== undefined).map((app) => app.id),
278
+ brokerReadAppIds: usable.filter(
279
+ (id) =>
280
+ evaluateAppAccess(authority, { id, access: { kind: "signedIn" } }) &&
281
+ evaluateAppAccess(brokerAuthority, { id, access: { kind: "signedIn" } }),
282
+ ),
252
283
  };
253
284
  };
254
285
 
255
286
  // App access applies to every grant; the older navigation audience keeps its legacy grant behavior.
256
- const effectivePermission = (skillId: SQLQuery, subject: AccessSubject | null, roles: SkillAudience) => sql`
257
- SELECT permission FROM (
287
+ const effectivePermission = (skillId: SQLQuery, subject: AiResourceContext | null, roles: SkillAudience) => sql`
288
+ SELECT CASE
289
+ WHEN permission = 'none' THEN permission
290
+ WHEN ${aiResourcePermissionCap(subject)} = 'none' THEN
291
+ CASE WHEN EXISTS (
292
+ SELECT 1 FROM ai.app_skills app WHERE app.skill_id=${skillId}
293
+ AND app.app_id = ANY(${toPgTextArray([...roles.brokerReadAppIds])}::text[])
294
+ ) THEN 'read'::auth.permission_level ELSE 'none'::auth.permission_level END
295
+ WHEN ${aiResourcePermissionCap(subject)} = 'read' THEN 'read'::auth.permission_level
296
+ WHEN ${aiResourcePermissionCap(subject)} = 'write' AND permission = 'admin' THEN 'write'::auth.permission_level
297
+ ELSE permission END AS permission FROM (
258
298
  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
259
299
  FROM ai.skill_access link JOIN auth.access access ON access.id=link.access_id
260
- WHERE link.skill_id=${skillId} AND ${accessMatch(subject)}
300
+ WHERE link.skill_id=${skillId} AND ${accessMatch(subject, roles)}
261
301
  UNION ALL
262
302
  SELECT 'read'::auth.permission_level, false AS direct FROM ai.project_skills link
263
303
  JOIN ai.project_access project_access ON project_access.project_id=link.project_id
264
304
  JOIN auth.access access ON access.id=project_access.access_id
265
- WHERE link.skill_id=${skillId} AND access.permission <> 'none' AND ${accessMatch(subject)}
305
+ WHERE link.skill_id=${skillId} AND access.permission <> 'none' AND ${accessMatch(subject, roles)}
266
306
  ) grants WHERE NOT EXISTS (
267
307
  SELECT 1 FROM ai.app_skills app WHERE app.skill_id=${skillId}
268
308
  AND (NOT app.available OR NOT(app.app_id = ANY(${toPgTextArray([...roles.usableAppIds])}::text[])) OR (NOT (app.app_id = ANY(${toPgTextArray([...roles.declaredAppIds])}::text[])) AND NOT grants.direct AND app.required_roles IS NOT NULL AND NOT(app.required_roles && ${toPgTextArray([...roles.roles])}::text[])))
@@ -271,7 +311,7 @@ const effectivePermission = (skillId: SQLQuery, subject: AccessSubject | null, r
271
311
 
272
312
  const permissionFor = async (
273
313
  skillId: string,
274
- subject: AccessSubject | null,
314
+ subject: AiResourceContext | null,
275
315
  db: SQL,
276
316
  roles: SkillAudience,
277
317
  ): Promise<AiSkillPermission | "none"> => {
@@ -283,7 +323,7 @@ const permissionFor = async (
283
323
 
284
324
  const canMutate = async (
285
325
  skillId: string,
286
- subject: AccessSubject | null,
326
+ subject: AiResourceContext | null,
287
327
  required: AiSkillPermission,
288
328
  db: SQL,
289
329
  roles: SkillAudience,
@@ -297,12 +337,13 @@ const canMutate = async (
297
337
  return false;
298
338
  };
299
339
 
300
- const enabledFor = async (skillId: string, subject: AccessSubject | null, db: SQL = sql): Promise<boolean> => {
301
- if (subject?.type !== "user") return true;
340
+ const enabledFor = async (skillId: string, subject: AiResourceContext | null, db: SQL = sql): Promise<boolean> => {
341
+ const accessSubject = aiResourceAccessSubject(subject);
342
+ if (accessSubject?.type !== "user") return true;
302
343
  const [disabled] = await db<{ disabled: boolean }[]>`
303
344
  SELECT true AS disabled
304
345
  FROM ai.skill_user_disabled
305
- WHERE skill_id = ${skillId}::uuid AND user_id = ${subject.userId}::uuid
346
+ WHERE skill_id = ${skillId}::uuid AND user_id = ${aiResourceUserId(subject)}::uuid
306
347
  `;
307
348
  return !disabled;
308
349
  };
@@ -314,7 +355,7 @@ export const listReferences = async (skillId: string, db: SQL = sql): Promise<Ai
314
355
  ORDER BY path
315
356
  `;
316
357
 
317
- const toSkill = async (row: SkillRow, subject: AccessSubject | null, db: SQL, roles: SkillAudience): Promise<AiSkill | null> => {
358
+ const toSkill = async (row: SkillRow, subject: AiResourceContext | null, db: SQL, roles: SkillAudience): Promise<AiSkill | null> => {
318
359
  const permission = await permissionFor(row.id, subject, db, roles);
319
360
  if (permission === "none") return null;
320
361
  const references = await listReferences(row.id, db);
@@ -343,7 +384,7 @@ const toSkill = async (row: SkillRow, subject: AccessSubject | null, db: SQL, ro
343
384
 
344
385
  const requireSkill = async (
345
386
  row: SkillRow | null,
346
- subject: AccessSubject | null,
387
+ subject: AiResourceContext | null,
347
388
  required: AiSkillPermission,
348
389
  db: SQL,
349
390
  roles: SkillAudience,
@@ -420,40 +461,33 @@ const checkAccessRevision = async (skillId: string, expected: string | undefined
420
461
  };
421
462
 
422
463
  const updateSkillAccess = async (skillId: string, accessId: string, permission: AiSkillPermission, db: SQL): Promise<boolean> => {
423
- const [bound] = await db<{ id: string; permission: AiSkillPermission }[]>`
424
- SELECT access.id, access.permission
464
+ const [bound] = await db<{ id: string }[]>`
465
+ SELECT access.id
425
466
  FROM ai.skill_access skill_access
426
467
  JOIN auth.access access ON access.id = skill_access.access_id
427
468
  WHERE skill_access.skill_id = ${skillId}::uuid AND skill_access.short_id = ${accessId}
428
469
  `;
429
470
  if (!bound) return false;
430
- if (bound.permission === "admin" && permission !== "admin") {
431
- const [admins] = await db<{ count: number }[]>`
432
- SELECT count(*)::int AS count FROM ai.skill_access skill_access
433
- JOIN auth.access access ON access.id = skill_access.access_id
434
- WHERE skill_access.skill_id = ${skillId}::uuid AND access.permission = 'admin'
435
- `;
436
- if ((admins?.count ?? 0) <= 1) throw new AiSkillLastAdminError();
437
- }
471
+ const before = await listSkillAccess(skillId, db);
472
+ const guarded = ensureManagerRemains({
473
+ before,
474
+ after: before.map((entry) => (entry.shortId === accessId ? { ...entry, permission } : entry)),
475
+ });
476
+ if (!guarded.ok) throw new AiSkillLastAdminError();
438
477
  return (await db`UPDATE auth.access SET permission = ${permission}::auth.permission_level WHERE id = ${bound.id}::uuid`).count === 1;
439
478
  };
440
479
 
441
480
  const revokeSkillAccess = async (skillId: string, accessId: string, db: SQL): Promise<boolean> => {
442
- const [bound] = await db<{ access_id: string; permission: AiSkillPermission }[]>`
443
- SELECT skill_access.access_id, access.permission
481
+ const [bound] = await db<{ access_id: string }[]>`
482
+ SELECT skill_access.access_id
444
483
  FROM ai.skill_access skill_access
445
484
  JOIN auth.access access ON access.id = skill_access.access_id
446
485
  WHERE skill_access.skill_id = ${skillId}::uuid AND skill_access.short_id = ${accessId}
447
486
  `;
448
487
  if (!bound) return false;
449
- if (bound.permission === "admin") {
450
- const [admins] = await db<{ count: number }[]>`
451
- SELECT count(*)::int AS count FROM ai.skill_access skill_access
452
- JOIN auth.access access ON access.id = skill_access.access_id
453
- WHERE skill_access.skill_id = ${skillId}::uuid AND access.permission = 'admin'
454
- `;
455
- if ((admins?.count ?? 0) <= 1) throw new AiSkillLastAdminError();
456
- }
488
+ const before = await listSkillAccess(skillId, db);
489
+ const guarded = ensureManagerRemains({ before, after: before.filter((entry) => entry.shortId !== accessId) });
490
+ if (!guarded.ok) throw new AiSkillLastAdminError();
457
491
  const deleted = await deleteAccess({ id: bound.access_id }, db);
458
492
  if (!deleted.ok) throw new Error(deleted.error.message);
459
493
  return true;
@@ -554,7 +588,7 @@ type SkillListOptions = {
554
588
  limit?: number;
555
589
  };
556
590
  async function listSkillSummaries(
557
- subject: AccessSubject | null,
591
+ subject: AiResourceContext | null,
558
592
  options: SkillListOptions & {
559
593
  project?: { id: string; linked: boolean };
560
594
  manageOnly?: boolean;
@@ -562,7 +596,7 @@ async function listSkillSummaries(
562
596
  } = {},
563
597
  ): Promise<AiSkillSummary[]> {
564
598
  const roles = await subjectRoles(subject, options.authority);
565
- const userId = subject?.type === "user" ? subject.userId : null;
599
+ const userId = aiResourceUserId(subject);
566
600
  const query = (options.query ?? "").trim().slice(0, 200);
567
601
  const limit = Math.max(1, Math.min(201, options.limit ?? 200));
568
602
  const rows = await withAiSkillSearch(query, async (bm25) => {
@@ -607,7 +641,7 @@ async function listSkillSummaries(
607
641
  }));
608
642
  }
609
643
 
610
- async function linkedSkillProjects(skillId: string, subject: AccessSubject | null) {
644
+ async function linkedSkillProjects(skillId: string, subject: AiResourceContext | null) {
611
645
  const links = await sql<
612
646
  { projectId: string }[]
613
647
  >`SELECT project_id AS "projectId" FROM ai.project_skills WHERE skill_id=${skillId}::uuid ORDER BY project_id`;
@@ -621,8 +655,12 @@ async function linkedSkillProjects(skillId: string, subject: AccessSubject | nul
621
655
 
622
656
  export type AiConversationSkillUse = { name: string; description: string; turns: number; lastLoadedAt: string };
623
657
 
624
- const listSkillTurnFiles = async (turnId: string, subject: AccessSubject | null, roles: SkillAudience): Promise<AiSkillFileToolStat[]> => {
625
- const userId = subject?.type === "user" ? subject.userId : null;
658
+ const listSkillTurnFiles = async (
659
+ turnId: string,
660
+ subject: AiResourceContext | null,
661
+ roles: SkillAudience,
662
+ ): Promise<AiSkillFileToolStat[]> => {
663
+ const userId = aiResourceUserId(subject);
626
664
  const rows = await sql<SnapshotRow[]>`
627
665
  SELECT DISTINCT snapshot.skill_name, snapshot.description, snapshot.revision, snapshot.instructions,
628
666
  snapshot.files, snapshot.loaded_at
@@ -671,7 +709,7 @@ export const aiSkills = {
671
709
 
672
710
  async create(
673
711
  input: {
674
- subject: AccessSubject;
712
+ subject: AiResourceContext;
675
713
  name: string;
676
714
  description: string;
677
715
  instructions: string;
@@ -680,6 +718,7 @@ export const aiSkills = {
680
718
  },
681
719
  authority: RequestAuthority | null = null,
682
720
  ): Promise<AiSkill> {
721
+ requireAiResourceCreation(input.subject);
683
722
  const fields = validatedFields(input);
684
723
  const roles = await subjectRoles(input.subject, authority);
685
724
  return sql.begin(async (tx) => {
@@ -706,13 +745,13 @@ export const aiSkills = {
706
745
  });
707
746
  },
708
747
 
709
- async list(subject: AccessSubject | null, options: SkillListOptions = {}): Promise<AiSkillSummary[]> {
748
+ async list(subject: AiResourceContext | null, options: SkillListOptions = {}): Promise<AiSkillSummary[]> {
710
749
  return listSkillSummaries(subject, options);
711
750
  },
712
751
 
713
752
  async projectSkills(
714
753
  projectId: string,
715
- subject: AccessSubject | null,
754
+ subject: AiResourceContext | null,
716
755
  options: { query?: string; page?: number; available?: boolean; authority?: RequestAuthority | null } = {},
717
756
  ) {
718
757
  if (!(await aiProjects.get(projectId, subject, options.available ? "admin" : "read"))) return null;
@@ -732,7 +771,7 @@ export const aiSkills = {
732
771
  skillId: string,
733
772
  projectId: string,
734
773
  linked: boolean,
735
- subject: AccessSubject | null,
774
+ subject: AiResourceContext | null,
736
775
  authority: RequestAuthority | null = null,
737
776
  ): Promise<boolean> {
738
777
  if (!(await aiProjects.get(projectId, subject, "admin"))) return false;
@@ -747,13 +786,13 @@ export const aiSkills = {
747
786
  });
748
787
  },
749
788
 
750
- async linkedProjects(skillId: string, subject: AccessSubject | null, authority: RequestAuthority | null = null) {
789
+ async linkedProjects(skillId: string, subject: AiResourceContext | null, authority: RequestAuthority | null = null) {
751
790
  if (!(await this.get(skillId, subject, "admin", authority))) return null;
752
791
  return linkedSkillProjects(skillId, subject);
753
792
  },
754
793
 
755
794
  async search(
756
- subject: AccessSubject | null,
795
+ subject: AiResourceContext | null,
757
796
  query: string,
758
797
  limit = 30,
759
798
  options: { modelInvocableOnly?: boolean; authority?: RequestAuthority | null } = {},
@@ -765,7 +804,7 @@ export const aiSkills = {
765
804
 
766
805
  async get(
767
806
  skillId: string,
768
- subject: AccessSubject | null,
807
+ subject: AiResourceContext | null,
769
808
  required: AiSkillPermission = "read",
770
809
  authority: RequestAuthority | null = null,
771
810
  ): Promise<AiSkill | null> {
@@ -774,7 +813,7 @@ export const aiSkills = {
774
813
 
775
814
  async getByShortId(
776
815
  shortId: string,
777
- subject: AccessSubject | null,
816
+ subject: AiResourceContext | null,
778
817
  required: AiSkillPermission = "read",
779
818
  authority: RequestAuthority | null = null,
780
819
  ): Promise<AiSkill | null> {
@@ -783,7 +822,7 @@ export const aiSkills = {
783
822
 
784
823
  async getByName(
785
824
  name: string,
786
- subject: AccessSubject | null,
825
+ subject: AiResourceContext | null,
787
826
  required: AiSkillPermission = "read",
788
827
  authority: RequestAuthority | null = null,
789
828
  ): Promise<AiSkill | null> {
@@ -792,7 +831,7 @@ export const aiSkills = {
792
831
 
793
832
  async update(
794
833
  skillId: string,
795
- subject: AccessSubject | null,
834
+ subject: AiResourceContext | null,
796
835
  input: {
797
836
  expectedRevision: number;
798
837
  name: string;
@@ -828,7 +867,7 @@ export const aiSkills = {
828
867
  });
829
868
  },
830
869
 
831
- async delete(skillId: string, subject: AccessSubject | null, authority: RequestAuthority | null = null): Promise<boolean> {
870
+ async delete(skillId: string, subject: AiResourceContext | null, authority: RequestAuthority | null = null): Promise<boolean> {
832
871
  const roles = await subjectRoles(subject, authority);
833
872
  return sql.begin(async (tx) => {
834
873
  const [row] = await tx<SkillRow[]>`SELECT * FROM ai.skills WHERE id = ${skillId}::uuid FOR UPDATE`;
@@ -844,7 +883,7 @@ export const aiSkills = {
844
883
 
845
884
  async listAccess(
846
885
  skillId: string,
847
- subject: AccessSubject | null,
886
+ subject: AiResourceContext | null,
848
887
  authority: RequestAuthority | null = null,
849
888
  ): Promise<AiSkillAccess[] | null> {
850
889
  const roles = await subjectRoles(subject, authority);
@@ -857,7 +896,7 @@ export const aiSkills = {
857
896
 
858
897
  async grantAccess(
859
898
  skillId: string,
860
- subject: AccessSubject | null,
899
+ subject: AiResourceContext | null,
861
900
  input: { principal: Principal; permission: AiSkillPermission },
862
901
  expectedAccessRevision?: string,
863
902
  authority: RequestAuthority | null = null,
@@ -874,7 +913,7 @@ export const aiSkills = {
874
913
  async updateAccess(
875
914
  skillId: string,
876
915
  accessId: string,
877
- subject: AccessSubject | null,
916
+ subject: AiResourceContext | null,
878
917
  permission: AiSkillPermission,
879
918
  expectedAccessRevision?: string,
880
919
  authority: RequestAuthority | null = null,
@@ -891,7 +930,7 @@ export const aiSkills = {
891
930
  async revokeAccess(
892
931
  skillId: string,
893
932
  accessId: string,
894
- subject: AccessSubject | null,
933
+ subject: AiResourceContext | null,
895
934
  expectedAccessRevision?: string,
896
935
  authority: RequestAuthority | null = null,
897
936
  ): Promise<boolean> {
@@ -1032,23 +1071,23 @@ export const aiSkills = {
1032
1071
 
1033
1072
  async setEnabled(
1034
1073
  skillId: string,
1035
- subject: AccessSubject | null,
1074
+ subject: AiResourceContext | null,
1036
1075
  enabled: boolean,
1037
1076
  authority: RequestAuthority | null = null,
1038
1077
  ): Promise<boolean | null> {
1039
- if (subject?.type !== "user") return null;
1078
+ if (!aiResourceUserId(subject)) return null;
1040
1079
  const roles = await subjectRoles(subject, authority);
1041
1080
  return sql.begin(async (tx) => {
1042
1081
  if (!(await requireSkill(await getRow(skillId, tx), subject, "read", tx, roles))) return null;
1043
1082
  if (enabled) {
1044
1083
  await tx`
1045
1084
  DELETE FROM ai.skill_user_disabled
1046
- WHERE skill_id = ${skillId}::uuid AND user_id = ${subject.userId}::uuid
1085
+ WHERE skill_id = ${skillId}::uuid AND user_id = ${aiResourceUserId(subject)}::uuid
1047
1086
  `;
1048
1087
  } else {
1049
1088
  await tx`
1050
1089
  INSERT INTO ai.skill_user_disabled (skill_id, user_id)
1051
- VALUES (${skillId}::uuid, ${subject.userId}::uuid)
1090
+ VALUES (${skillId}::uuid, ${aiResourceUserId(subject)}::uuid)
1052
1091
  ON CONFLICT (skill_id, user_id) DO NOTHING
1053
1092
  `;
1054
1093
  }
@@ -1058,7 +1097,7 @@ export const aiSkills = {
1058
1097
 
1059
1098
  async setReference(
1060
1099
  skillId: string,
1061
- subject: AccessSubject | null,
1100
+ subject: AiResourceContext | null,
1062
1101
  input: { expectedRevision: number; path: string; content: string },
1063
1102
  authority: RequestAuthority | null = null,
1064
1103
  ): Promise<AiSkill | null> {
@@ -1075,7 +1114,7 @@ export const aiSkills = {
1075
1114
 
1076
1115
  async setReferences(
1077
1116
  skillId: string,
1078
- subject: AccessSubject | null,
1117
+ subject: AiResourceContext | null,
1079
1118
  input: { expectedRevision: number; references: readonly AiSkillReferenceInput[] },
1080
1119
  authority: RequestAuthority | null = null,
1081
1120
  ): Promise<AiSkill | null> {
@@ -1109,7 +1148,7 @@ export const aiSkills = {
1109
1148
 
1110
1149
  async removeReference(
1111
1150
  skillId: string,
1112
- subject: AccessSubject | null,
1151
+ subject: AiResourceContext | null,
1113
1152
  input: { expectedRevision: number; path: string },
1114
1153
  authority: RequestAuthority | null = null,
1115
1154
  ): Promise<AiSkill | null> {
@@ -1135,7 +1174,7 @@ export const aiSkills = {
1135
1174
  async loadForTurn(
1136
1175
  turnId: string,
1137
1176
  selector: string | { id: string },
1138
- subject: AccessSubject | null,
1177
+ subject: AiResourceContext | null,
1139
1178
  authority: RequestAuthority | null = null,
1140
1179
  ): Promise<AiLoadedSkillSnapshot | null> {
1141
1180
  const name = typeof selector === "string" ? validateAiSkillName(selector) : null;
@@ -1181,7 +1220,7 @@ export const aiSkills = {
1181
1220
 
1182
1221
  async listTurnFiles(
1183
1222
  turnId: string,
1184
- subject: AccessSubject | null,
1223
+ subject: AiResourceContext | null,
1185
1224
  authority: RequestAuthority | null = null,
1186
1225
  ): Promise<AiSkillFileToolStat[]> {
1187
1226
  return listSkillTurnFiles(turnId, subject, await subjectRoles(subject, authority));
@@ -1190,7 +1229,7 @@ export const aiSkills = {
1190
1229
  async readTurnFile(
1191
1230
  turnId: string,
1192
1231
  path: string,
1193
- subject: AccessSubject | null,
1232
+ subject: AiResourceContext | null,
1194
1233
  authority: RequestAuthority | null = null,
1195
1234
  ): Promise<AiSkillFileToolContent | null> {
1196
1235
  const roles = await subjectRoles(subject, authority);
@@ -1198,7 +1237,7 @@ export const aiSkills = {
1198
1237
  const stat = files.find((file) => file.path === path);
1199
1238
  if (!stat) return null;
1200
1239
  const [skillName] = path.split("/", 1);
1201
- const userId = subject?.type === "user" ? subject.userId : null;
1240
+ const userId = aiResourceUserId(subject);
1202
1241
  const [row] = await sql<SnapshotRow[]>`
1203
1242
  SELECT DISTINCT snapshot.skill_name, snapshot.description, snapshot.revision, snapshot.instructions,
1204
1243
  snapshot.files, snapshot.loaded_at
@@ -36,6 +36,7 @@ const QuerySchema = z
36
36
  profile: UserProfileSchema.optional(),
37
37
  user_ids: z.string().optional(),
38
38
  group_ids: z.string().optional(),
39
+ service_account_ids: z.string().optional(),
39
40
  service_account_kinds: z.string().optional(),
40
41
  service_account_status: ServiceAccountStatusSchema.optional(),
41
42
  exclude_user_ids: z.string().optional(),
@@ -47,7 +48,6 @@ const QuerySchema = z
47
48
  parent_group_id: z.uuid().optional(),
48
49
  managed_by_user_id: z.uuid().optional(),
49
50
  recursive: z.enum(["true", "false"]).optional(),
50
- include_personal: z.enum(["true", "false"]).optional(),
51
51
  })
52
52
  .refine(
53
53
  (value) => {
@@ -112,7 +112,7 @@ export const createAccountsEntitiesRoutes = (dependencies: AccountsEntitiesRoute
112
112
  tags: ["Accounts"],
113
113
  summary: "List visible directory entries",
114
114
  description:
115
- "List visible users and groups with SQL-backed filtering and pagination. Guest accounts see only themselves and their effective groups; relation filters require a full user account. Standalone and agent accounts with read scope see only explicitly shared directory entries and cannot query relationships. Personal Linux groups (a user's private primary group, flagged with `personalOwner`) are left out unless `include_personal=true`; exact `group_ids` lookups and relation filters always return them.",
115
+ "List visible users and groups with SQL-backed filtering and pagination. Guest accounts see only themselves and their effective groups; relation filters require a full user account. Standalone and agent accounts with read scope see only explicitly shared directory entries and cannot query relationships. Personal Linux groups are excluded, including exact group IDs and relation filters. Inspect the owning user's Linux identity for diagnostic attributes.",
116
116
  ...requiresAuth,
117
117
  responses: {
118
118
  200: jsonResponse(EntitiesListResponseSchema, "Paginated mixed entity list"),
@@ -153,6 +153,9 @@ export const createAccountsEntitiesRoutes = (dependencies: AccountsEntitiesRoute
153
153
  const groupIds = parseUuidList(query.group_ids, "group_ids");
154
154
  if (!groupIds.ok) return respond(c, fail(err.badInput(groupIds.message)));
155
155
 
156
+ const serviceAccountIds = parseUuidList(query.service_account_ids, "service_account_ids");
157
+ if (!serviceAccountIds.ok) return respond(c, fail(err.badInput(serviceAccountIds.message)));
158
+
156
159
  const filters = {
157
160
  search: query.search,
158
161
  kinds: kinds.value,
@@ -160,6 +163,7 @@ export const createAccountsEntitiesRoutes = (dependencies: AccountsEntitiesRoute
160
163
  profile: query.profile,
161
164
  userIds: userIds.value,
162
165
  groupIds: groupIds.value,
166
+ serviceAccountIds: serviceAccountIds.value,
163
167
  serviceAccountKinds: serviceAccountKinds.value,
164
168
  serviceAccountStatus: query.service_account_status,
165
169
  excludeUserIds: excludeUserIds.value,
@@ -171,7 +175,6 @@ export const createAccountsEntitiesRoutes = (dependencies: AccountsEntitiesRoute
171
175
  parentGroupId: query.parent_group_id,
172
176
  managedByUserId: query.managed_by_user_id,
173
177
  recursive: query.recursive === "true",
174
- includePersonal: query.include_personal === "true",
175
178
  };
176
179
  if (!user) {
177
180
  const result = await (dependencies.searchShares ?? directoryShares.search)({
@@ -94,7 +94,10 @@ export const createAdminAiSkillsRoutes = (authenticate: MiddlewareHandler<AuthCo
94
94
  .get("/:skillId/projects", async (c) => {
95
95
  const skill = await aiSkills.admin.getByShortId(c.req.param("skillId")!);
96
96
  if (!skill) return notFound(c, "Skill");
97
- const projects = await aiSkills.admin.linkedProjects(skill.id, c.get("accessSubject") ?? null);
97
+ const projects = await aiSkills.admin.linkedProjects(skill.id, {
98
+ actor: c.get("actor"),
99
+ accessSubject: c.get("accessSubject"),
100
+ });
98
101
  return respond(c, ok({ projects: projects.map((project) => ({ id: project.shortId, name: project.name })) }));
99
102
  })
100
103
  .get("/:skillId/access", async (c) => {
package/src/api/auth.ts CHANGED
@@ -162,13 +162,20 @@ export const createAuthRoutes = (notificationSender: AuthNotificationSender) =>
162
162
  tags: ["Auth"],
163
163
  summary: "Logout",
164
164
  description:
165
- "Idempotent: clears the session cookie and revokes the current credential if present. No authentication required — logout must always succeed.",
165
+ "Idempotent: clears the session cookie and revokes the current credential if present. No authentication required. An optional X-Cloud-Logout-User header binds revocation to that user UUID (or none) without writing the shared browser cookie; a changed account returns 409 without revocation.",
166
+ parameters: [{ in: "header", name: "X-Cloud-Logout-User", required: false, schema: { type: "string" } }],
166
167
  responses: {
167
168
  200: jsonResponse(MessageResponseSchema, "Session invalidated"),
169
+ 400: jsonResponse(ErrorResponseSchema, "Invalid expected account"),
170
+ 409: jsonResponse(ErrorResponseSchema, "Browser account changed"),
168
171
  },
169
172
  }),
170
173
  async (c) => {
171
- await auth.session.delete(c);
174
+ const expected = c.req.header("x-cloud-logout-user");
175
+ if (expected !== undefined && expected !== "none" && !z.string().uuid().safeParse(expected).success)
176
+ return c.json({ message: "Invalid expected account" }, 400);
177
+ if (!(await auth.session.delete(c, expected === "none" ? null : expected)))
178
+ return c.json({ message: "Browser account changed; reload before signing out" }, 409);
172
179
  log.info("Logout");
173
180
  return c.json({ message: "Logged out" });
174
181
  },
@@ -0,0 +1,39 @@
1
+ import { apiClient } from "../clients/core";
2
+
3
+ /** Fence and remove application-owned entries in the authenticated user's browser namespace. */
4
+ const clearUserStorage = (userId: string): void => {
5
+ const prefix = `cloud:user:${userId}:`;
6
+ localStorage.setItem(`cloud:user-epoch:${userId}`, crypto.randomUUID());
7
+ if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("cloud:user-storage-invalidated", { detail: { userId } }));
8
+ const keys = Array.from({ length: localStorage.length }, (_, index) => localStorage.key(index));
9
+ for (const key of keys) if (key?.startsWith(prefix)) localStorage.removeItem(key);
10
+ };
11
+
12
+ /** Revoke this browser's credential even if local storage cleanup fails. The caller keeps a visible retry until both succeed. */
13
+ export const signOutBrowserSession = async (userId: string | undefined, errorMessage: string): Promise<void> => {
14
+ await import("./notifications").then(({ browserNotificationClient }) => browserNotificationClient.disable()).catch(() => undefined);
15
+ let cleanupFailed = false;
16
+ try {
17
+ if (userId) {
18
+ clearUserStorage(userId);
19
+ const { logoutSharedTextOffline } = await import("./shared-text-offline");
20
+ await logoutSharedTextOffline(userId);
21
+ }
22
+ } catch {
23
+ cleanupFailed = true;
24
+ }
25
+ const response = await apiClient.auth.logout.$post({}, { headers: { "x-cloud-logout-user": userId ?? "none" } });
26
+ // Close the interval between the first local fence and credential revocation:
27
+ // a tab that authenticated during it must not retain or recreate device data.
28
+ if (response.ok && userId) {
29
+ try {
30
+ clearUserStorage(userId);
31
+ const { logoutSharedTextOffline } = await import("./shared-text-offline");
32
+ await logoutSharedTextOffline(userId);
33
+ cleanupFailed = false;
34
+ } catch {
35
+ cleanupFailed = true;
36
+ }
37
+ }
38
+ if (!response.ok || cleanupFailed) throw new Error(errorMessage);
39
+ };