@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/cloud",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "description": "Application platform library for independently deployed Hono and SolidJS services behind a dynamic gateway.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "repository": {
@@ -31,6 +31,8 @@
31
31
  "./server/*": "./src/server/*",
32
32
  "./geo/ui": "./src/geo/ui.ts",
33
33
  "./browser": "./src/server/api-client.ts",
34
+ "./browser/session": "./src/browser/session.ts",
35
+ "./browser/shared-text": "./src/browser/shared-text.ts",
34
36
  "./browser/app-approval": "./src/browser/app-approval.ts",
35
37
  "./browser/app-badges": "./src/browser/app-badges.ts",
36
38
  "./browser/live": "./src/browser/live.ts",
@@ -123,7 +125,7 @@
123
125
  "@tailwindcss/typography": "0.5.20",
124
126
  "@k2b/nessi": "0.18.0",
125
127
  "@k2b/ssr": "0.20.1",
126
- "@k2b/ui": "0.34.0",
128
+ "@k2b/ui": "0.35.0",
127
129
  "@k2b/stdlib": "0.30.0",
128
130
  "@k2b/sync": "7.0.1",
129
131
  "@nats-io/jetstream": "3.4.0",
@@ -136,6 +138,7 @@
136
138
  "htmlparser2": "12.0.0",
137
139
  "jose": "6.2.12",
138
140
  "katex": "0.18.9",
141
+ "lib0": "0.2.117",
139
142
  "liquidjs": "10.29.0",
140
143
  "marked": "18.0.14",
141
144
  "mermaid": "12.0.0",
@@ -149,6 +152,7 @@
149
152
  "web-push": "3.6.7",
150
153
  "web-vitals": "6.2.2",
151
154
  "yaml": "2.9.1",
155
+ "y-protocols": "1.0.7",
152
156
  "yjs": "13.6.33",
153
157
  "hash-wasm": "4.12.0",
154
158
  "@types/sanitize-html": "2.16.1",
@@ -2,16 +2,18 @@ import { query } from "@k2b/stdlib/solid";
2
2
  import { Button, InlineGuidance, useLocale } from "@k2b/ui";
3
3
  import { createSignal, createUniqueId, For, Show } from "solid-js";
4
4
  import { CloudAvatar } from "../account/Avatar";
5
- import type { BaseUser, EntityListItem, PaginationResponse } from "../contracts/shared";
5
+ import type { EntityListItem, PaginationResponse } from "../contracts/shared";
6
6
  import { accessMessages } from "./messages";
7
+ import { serviceAccountKindDisplay } from "./service-account-kind";
7
8
 
8
9
  /** Members per request. Further pages load only when the viewer asks for them. */
9
10
  const PAGE_SIZE = 20;
11
+ type GroupMember = Exclude<EntityListItem, { kind: "group" }>;
10
12
 
11
13
  type MembersPage =
12
14
  /** The directory withholds group members from this viewer (guest accounts). */
13
15
  | { visible: false; directoryGroup: boolean }
14
- | { visible: true; directoryGroup: boolean; members: BaseUser[]; total: number; page: number; hasNext: boolean };
16
+ | { visible: true; directoryGroup: boolean; members: GroupMember[]; total: number; page: number; hasNext: boolean };
15
17
 
16
18
  /** Reads the Accounts directory with the viewer's own visibility: `null` when it is withheld. */
17
19
  const listEntities = async (params: Record<string, string>, signal: AbortSignal) => {
@@ -38,7 +40,7 @@ export const createGroupCoverage = (groupId: string) => {
38
40
  const page = cursor ?? 1;
39
41
  const [list, group] = await Promise.all([
40
42
  listEntities(
41
- { kinds: "user", member_of_group_id: id, recursive: "true", page: String(page), per_page: String(PAGE_SIZE) },
43
+ { kinds: "user,service_account", member_of_group_id: id, recursive: "true", page: String(page), per_page: String(PAGE_SIZE) },
42
44
  abortSignal,
43
45
  ),
44
46
  // Only the first page needs the group itself, for the directory hint.
@@ -49,7 +51,7 @@ export const createGroupCoverage = (groupId: string) => {
49
51
  return {
50
52
  visible: true,
51
53
  directoryGroup,
52
- members: list.items.flatMap((item) => (item.kind === "user" ? [item.user] : [])),
54
+ members: list.items.filter((item) => item.kind !== "group"),
53
55
  total: list.pagination.total,
54
56
  page,
55
57
  hasNext: list.pagination.has_next,
@@ -146,7 +148,20 @@ export function GroupCoveragePanel(props: { coverage: GroupCoverage; groupName:
146
148
  <Show when={shown().length > 0}>
147
149
  <ul ref={list} tabindex="-1" aria-label={t().groupMembersOf({ name: props.groupName })} class="flex flex-col gap-1 outline-none">
148
150
  <For each={shown()}>
149
- {(user) => {
151
+ {(member) => {
152
+ if (member.kind === "service_account") {
153
+ const kind = () => serviceAccountKindDisplay(member.serviceAccount.kind, t());
154
+ return (
155
+ <li class="flex min-w-0 items-center gap-2 text-sm">
156
+ <span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-secondary dark:bg-zinc-700">
157
+ <i class={`ti ${kind().icon}`} aria-hidden="true" />
158
+ </span>
159
+ <span class="truncate">{member.serviceAccount.name}</span>
160
+ <span class="shrink-0 text-xs text-muted">{kind().label}</span>
161
+ </li>
162
+ );
163
+ }
164
+ const user = member.user;
150
165
  const name = user.displayName || user.mail || user.uid;
151
166
  return (
152
167
  <li class="flex min-w-0 items-center gap-2 text-sm">
@@ -449,6 +449,8 @@ export default function PermissionEditor(props: PermissionEditorProps) {
449
449
  allowPublic={allowPublic()}
450
450
  allowAuthenticated={allowAuthenticated()}
451
451
  allowServiceAccounts={props.allowServiceAccounts}
452
+ serviceAccountKinds={props.serviceAccountKinds}
453
+ serviceAccountStatus={props.serviceAccountStatus ?? "active"}
452
454
  placeholder={props.placeholder}
453
455
  signedIn={props.signedIn}
454
456
  disabled={saving()}
@@ -651,7 +653,7 @@ function AccessEntryRow(props: {
651
653
  >
652
654
  <Show when={!props.expanded} fallback={<PrincipalAvatar entry={props.entry} label={displayName()} />}>
653
655
  <Show
654
- when={props.entry.principal.type === "user"}
656
+ when={props.entry.principal.type === "user" || props.entry.principal.type === "service_account"}
655
657
  fallback={
656
658
  <div class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-zinc-200 text-secondary dark:bg-zinc-700">
657
659
  <i class={`ti ${getPrincipalIcon(props.entry, t())} text-sm`} />
@@ -660,8 +662,15 @@ function AccessEntryRow(props: {
660
662
  >
661
663
  <CloudAvatar
662
664
  username={displayName()}
663
- userId={props.entry.principal.type === "user" ? props.entry.principal.userId : undefined}
665
+ userId={
666
+ props.entry.principal.type === "user"
667
+ ? props.entry.principal.userId
668
+ : props.entry.principal.type === "service_account"
669
+ ? props.entry.principal.serviceAccountId
670
+ : undefined
671
+ }
664
672
  avatarHash={props.entry.avatarHash}
673
+ kind={props.entry.principal.type === "service_account" ? "service_account" : "user"}
665
674
  size="xs"
666
675
  class="h-7 w-7"
667
676
  />
@@ -2,9 +2,9 @@ import { Show } from "solid-js";
2
2
  import { CloudAvatar } from "../account/Avatar";
3
3
  import type { AccessEntry } from "../contracts/shared";
4
4
 
5
- // One hue per principal kind from the avatar palette, so a group, a service account and all signed-in people
6
- // read apart at a glance. People use a neutral background.
7
- const hues = { group: "#22c55e", service_account: "#8b5cf6", authenticated: "#3b82f6" } as const;
5
+ // One hue per principal kind from the avatar palette, so a group and all signed-in people read apart at a glance.
6
+ // People use a neutral background; a service account shows its robot or photo, never a bare icon.
7
+ const hues = { group: "#22c55e", authenticated: "#3b82f6" } as const;
8
8
 
9
9
  /** The 32px avatar of an access entry in the expanded editor and on the denial page. */
10
10
  export default function PrincipalAvatar(props: {
@@ -15,8 +15,6 @@ export default function PrincipalAvatar(props: {
15
15
  switch (props.entry.principal.type) {
16
16
  case "group":
17
17
  return "ti-users-group";
18
- case "service_account":
19
- return "ti-robot";
20
18
  case "authenticated":
21
19
  return "ti-world";
22
20
  default:
@@ -25,27 +23,38 @@ export default function PrincipalAvatar(props: {
25
23
  };
26
24
  const hue = () => {
27
25
  const type = props.entry.principal.type;
28
- return type === "group" || type === "service_account" || type === "authenticated" ? hues[type] : undefined;
26
+ return type === "group" || type === "authenticated" ? hues[type] : undefined;
29
27
  };
30
28
  return (
31
29
  <Show
32
30
  when={props.entry.principal.type === "user" ? props.entry.principal : undefined}
33
31
  fallback={
34
- <span
35
- class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--ui-surface-muted)] text-secondary"
36
- // The avatar formula: a light share of the hue on the surface, a dark share on the text, in both themes.
37
- style={
38
- hue()
39
- ? {
40
- background: `color-mix(in srgb, ${hue()} 18%, var(--k2b-surface))`,
41
- color: `color-mix(in srgb, ${hue()} 55%, var(--k2b-text))`,
42
- }
43
- : undefined
44
- }
45
- aria-hidden="true"
46
- >
47
- <i class={`ti ${icon()} text-xs`} />
48
- </span>
32
+ props.entry.principal.type === "service_account" ? (
33
+ <CloudAvatar
34
+ username={props.label}
35
+ userId={props.entry.principal.serviceAccountId}
36
+ avatarHash={props.entry.avatarHash}
37
+ kind="service_account"
38
+ size="sm"
39
+ class="h-8 w-8"
40
+ />
41
+ ) : (
42
+ <span
43
+ class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--ui-surface-muted)] text-secondary"
44
+ // The avatar formula: a light share of the hue on the surface, a dark share on the text, in both themes.
45
+ style={
46
+ hue()
47
+ ? {
48
+ background: `color-mix(in srgb, ${hue()} 18%, var(--k2b-surface))`,
49
+ color: `color-mix(in srgb, ${hue()} 55%, var(--k2b-text))`,
50
+ }
51
+ : undefined
52
+ }
53
+ aria-hidden="true"
54
+ >
55
+ <i class={`ti ${icon()} text-xs`} />
56
+ </span>
57
+ )
49
58
  }
50
59
  >
51
60
  {(user) => (
@@ -47,8 +47,8 @@ export const accessMessages = i18n.define({
47
47
  count === 0
48
48
  ? "No one receives access through this group right now."
49
49
  : count === 1
50
- ? "1 person receives access through this group right now."
51
- : `${count.toLocaleString("en")} people receive access through this group right now.`,
50
+ ? "1 account receives access through this group right now."
51
+ : `${count.toLocaleString("en")} accounts receive access through this group right now.`,
52
52
  groupMembersLoading: "Loading members…",
53
53
  groupMembersHidden: "Your account can't see who belongs to this group.",
54
54
  groupMembersFailed: "Members couldn't be loaded.",
@@ -137,8 +137,8 @@ export const accessMessages = i18n.define({
137
137
  count === 0
138
138
  ? "Über diese Gruppe erhält gerade niemand Zugriff."
139
139
  : count === 1
140
- ? "Über diese Gruppe erhält gerade 1 Person Zugriff."
141
- : `Über diese Gruppe erhalten gerade ${count.toLocaleString("de")} Personen Zugriff.`,
140
+ ? "Über diese Gruppe erhält gerade 1 Konto Zugriff."
141
+ : `Über diese Gruppe erhalten gerade ${count.toLocaleString("de")} Konten Zugriff.`,
142
142
  groupMembersLoading: "Mitglieder werden geladen…",
143
143
  groupMembersHidden: "Dein Konto kann nicht sehen, wer zu dieser Gruppe gehört.",
144
144
  groupMembersFailed: "Die Mitglieder konnten nicht geladen werden.",
@@ -5,6 +5,7 @@ import { createSignal, For, Show } from "solid-js";
5
5
  import { accessMessages } from "../access/messages";
6
6
  import { principalLabel } from "../access/principal-label";
7
7
  import { serviceAccountKindDisplay } from "../access/service-account-kind";
8
+ import type { ServiceAccountKind, ServiceAccountStatus } from "../contracts/shared";
8
9
  import { groupDisplayName } from "../shared/account-display";
9
10
  import { CloudAvatar } from "./Avatar";
10
11
 
@@ -52,6 +53,10 @@ type EntitySearchProps = {
52
53
  includeGroups?: boolean;
53
54
  /** Surface service accounts in the result list. Off by default. */
54
55
  includeServiceAccounts?: boolean;
56
+ /** Restrict service-account kinds before pagination. Omitted or empty allows all kinds. */
57
+ serviceAccountKinds?: ServiceAccountKind[];
58
+ /** Restrict service-account status before pagination. Omitted allows both statuses. */
59
+ serviceAccountStatus?: ServiceAccountStatus;
55
60
  /** Inject a synthetic "All authenticated users" row at the top. */
56
61
  includeAuthenticated?: boolean;
57
62
  /** Inject a synthetic "Public" row at the top. */
@@ -171,6 +176,12 @@ const EntitySearch = (props: EntitySearchProps) => {
171
176
  if (props.excludeServiceAccountIds?.length) {
172
177
  url.searchParams.set("exclude_service_account_ids", props.excludeServiceAccountIds.join(","));
173
178
  }
179
+ if (props.includeServiceAccounts && props.serviceAccountKinds?.length) {
180
+ url.searchParams.set("service_account_kinds", props.serviceAccountKinds.join(","));
181
+ }
182
+ if (props.includeServiceAccounts && props.serviceAccountStatus) {
183
+ url.searchParams.set("service_account_status", props.serviceAccountStatus);
184
+ }
174
185
  if (props.onlyMembersOf?.length) {
175
186
  url.searchParams.set("user_member_of_group_ids", props.onlyMembersOf.join(","));
176
187
  }
@@ -296,6 +307,7 @@ const EntitySearch = (props: EntitySearchProps) => {
296
307
  {(serviceAccount) => (
297
308
  <ResultRow
298
309
  icon={serviceAccountKind(serviceAccount).icon}
310
+ avatar={<CloudAvatar username={serviceAccount.name} userId={serviceAccount.id} kind="service_account" size="sm" />}
299
311
  title={serviceAccount.name}
300
312
  subtitle={
301
313
  // Resource-bound accounts often share a name; their binding tells them apart.
@@ -1,5 +1,6 @@
1
1
  import { sql } from "bun";
2
2
  import { lazySync } from "../_internal/process-sync";
3
+ import { get as getUser } from "../services/accounts/users";
3
4
  import { logger, trace } from "../services/logging";
4
5
  import { superviseRuntimeTask } from "../services/runtime-lifecycle";
5
6
  import { aiModelAccess } from "./model-access";
@@ -85,8 +86,11 @@ export const processAiDictation = async (
85
86
  if (!conversation) throw new Error("Conversation unavailable.");
86
87
  const subject = { type: "user" as const, userId: task.user_id };
87
88
  await aiModelAccess.assertAllowed(task.model_profile_id, subject);
88
- if (conversation.project_id && !(await aiProjects.snapshot(conversation.project_id, subject)))
89
- throw new Error("Project access unavailable.");
89
+ if (conversation.project_id) {
90
+ const user = await getUser({ id: task.user_id });
91
+ if (!user || !(await aiProjects.snapshot(conversation.project_id, { actor: { kind: "user", user }, accessSubject: subject })))
92
+ throw new Error("Project access unavailable.");
93
+ }
90
94
  const result = await run({
91
95
  task: "dictation",
92
96
  traceParent: span,
@@ -57,6 +57,7 @@ import { answerOpenToolCalls } from "./open-tool-calls";
57
57
  import { type AiUserPrefs, aiActorUser, aiUserPrefs } from "./prefs";
58
58
  import { createCloudAiReadProjectKnowledgeTool, createCloudAiSearchProjectTool } from "./project-tool";
59
59
  import { aiProjects } from "./projects";
60
+ import type { AiResourceContext } from "./projects-authorization";
60
61
  import {
61
62
  AI_TURN_LEASE_MS,
62
63
  type AiTurnBlock,
@@ -306,6 +307,11 @@ const accessSubjectForActor = (actor: RequestActor | undefined): AccessSubject |
306
307
  return { type: "service_account", serviceAccountId: actor.serviceAccount.id };
307
308
  };
308
309
 
310
+ const resourceContextForActor = (actor: RequestActor | undefined): AiResourceContext | null => {
311
+ const accessSubject = accessSubjectForActor(actor);
312
+ return actor && accessSubject ? { actor, accessSubject } : null;
313
+ };
314
+
309
315
  export type ExecutorConfig = {
310
316
  leaseOwner: string;
311
317
  heartbeatMs: number;
@@ -883,7 +889,7 @@ export class AiTurnExecutor {
883
889
  if (allowed) material.tools = material.tools.filter((tool) => allowed.has(tool.def.name));
884
890
  chatId ||= conversation?.shortId ?? "";
885
891
  if (config.project) {
886
- const subject = accessSubjectForActor(material.actor);
892
+ const subject = resourceContextForActor(material.actor);
887
893
  const project = subject ? await aiProjects.getByShortId(config.project.id, subject, "read") : null;
888
894
  if (!project) {
889
895
  throw new AiTurnFailure("not_allowed", "Project access is no longer available.");
@@ -966,11 +972,11 @@ export class AiTurnExecutor {
966
972
  const timeZone = String((await coreSettings.get<string>("app.timezone")) || "").trim() || "UTC";
967
973
  const promptLocale = normalizeLocale(config.locale ?? (await coreSettings.get<string>("app.locale")));
968
974
  const project = config.project;
969
- const projectSubject = project ? accessSubjectForActor(material.actor) : null;
975
+ const projectSubject = project ? resourceContextForActor(material.actor) : null;
970
976
  // Skills are an Assistant/default-tool capability. Custom and structured
971
977
  // executions must not gain an implicit database dependency or extra tools.
972
978
  const skillSubject =
973
- defaultToolSource && resolved.profile.capabilities.includes("tools") ? accessSubjectForActor(material.actor) : null;
979
+ defaultToolSource && resolved.profile.capabilities.includes("tools") ? resourceContextForActor(material.actor) : null;
974
980
  const availableSkills = skillSubject
975
981
  ? (await aiSkills.list(skillSubject, { authority: toolAuthority })).filter((skill) => skill.enabled)
976
982
  : [];
package/src/ai/index.ts CHANGED
@@ -290,6 +290,7 @@ export {
290
290
  type AiProjectReference,
291
291
  aiProjects,
292
292
  } from "./projects";
293
+ export { type AiResourceContext, AiResourceCredentialError } from "./projects-authorization";
293
294
  export {
294
295
  type AiProjectsRoutes,
295
296
  aiProjectsRoutes,
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import type { AccessSubject } from "../server";
3
2
  import { mountAiProjectFilePath } from "./file-mount";
4
3
  import { aiProjects } from "./projects";
4
+ import type { AiResourceContext } from "./projects-authorization";
5
5
  import { AI_SHORT_ID_PATTERN } from "./short-id";
6
6
  import { defineAiTool } from "./tools";
7
7
 
@@ -31,11 +31,11 @@ export const CloudAiReadProjectKnowledgeInputSchema = z
31
31
 
32
32
  export const CloudAiReadProjectKnowledgeOutputSchema = z.object({ title: z.string(), content: z.string() }).strict();
33
33
 
34
- const requireProjectAccess = async (projectId: string, subject: AccessSubject): Promise<void> => {
34
+ const requireProjectAccess = async (projectId: string, subject: AiResourceContext): Promise<void> => {
35
35
  if (!(await aiProjects.get(projectId, subject, "read"))) throw new Error("Project access is no longer available.");
36
36
  };
37
37
 
38
- export const createCloudAiSearchProjectTool = (projectId: string, subject: AccessSubject) =>
38
+ export const createCloudAiSearchProjectTool = (projectId: string, subject: AiResourceContext) =>
39
39
  defineAiTool({
40
40
  name: "search_project",
41
41
  description:
@@ -77,7 +77,7 @@ export const createCloudAiSearchProjectTool = (projectId: string, subject: Acces
77
77
  return { items: all.slice(0, 100), truncated: all.length > 100 };
78
78
  });
79
79
 
80
- export const createCloudAiReadProjectKnowledgeTool = (projectId: string, subject: AccessSubject) =>
80
+ export const createCloudAiReadProjectKnowledgeTool = (projectId: string, subject: AiResourceContext) =>
81
81
  defineAiTool({
82
82
  name: "read_project_knowledge",
83
83
  description:
@@ -0,0 +1,53 @@
1
+ import type { AccessSubject, RequestActor } from "../server";
2
+ import { hasPermission, type PermissionLevel } from "../server/services/access";
3
+
4
+ /** Resource operations retain the actor's credential alongside the grant subject. */
5
+ export type AiResourceContext = { actor: RequestActor; accessSubject: AccessSubject };
6
+
7
+ export const aiResourceAccessSubject = (subject: AiResourceContext | null): AccessSubject | null => subject?.accessSubject ?? null;
8
+
9
+ /** Projects and Skills have no supported resource-bound credential contract. */
10
+ export const aiResourcePermissionCap = (subject: AiResourceContext | null): PermissionLevel => {
11
+ if (!subject?.actor || !subject.accessSubject) return "none";
12
+ const { actor, accessSubject } = subject;
13
+ if (actor.kind === "user") {
14
+ return accessSubject.type === "user" && accessSubject.userId === actor.user.id ? "admin" : "none";
15
+ }
16
+ if (actor.serviceAccount.kind === "resource_bound") return "none";
17
+ if (actor.delegatedUser) {
18
+ return actor.serviceAccount.kind === "user_delegated" &&
19
+ accessSubject.type === "user" &&
20
+ accessSubject.userId === actor.delegatedUser.id
21
+ ? "admin"
22
+ : "none";
23
+ }
24
+ if (
25
+ (actor.serviceAccount.kind !== "standalone" && actor.serviceAccount.kind !== "agent") ||
26
+ accessSubject.type !== "service_account" ||
27
+ accessSubject.serviceAccountId !== actor.serviceAccount.id
28
+ )
29
+ return "none";
30
+ return actor.scopes.includes("admin")
31
+ ? "admin"
32
+ : actor.scopes.includes("write")
33
+ ? "write"
34
+ : actor.scopes.includes("read")
35
+ ? "read"
36
+ : "none";
37
+ };
38
+
39
+ export const capAiResourcePermission = (granted: PermissionLevel, subject: AiResourceContext | null): PermissionLevel => {
40
+ const cap = aiResourcePermissionCap(subject);
41
+ return hasPermission(granted, cap) ? cap : granted;
42
+ };
43
+
44
+ export class AiResourceCredentialError extends Error {
45
+ constructor() {
46
+ super("The credential does not permit creating Projects or Skills.");
47
+ this.name = "AiResourceCredentialError";
48
+ }
49
+ }
50
+
51
+ export const requireAiResourceCreation = (subject: AiResourceContext): void => {
52
+ if (!hasPermission(aiResourcePermissionCap(subject), "write")) throw new AiResourceCredentialError();
53
+ };