@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.
- package/package.json +6 -2
- package/src/access/GroupCoverage.tsx +20 -5
- package/src/access/PermissionEditor.tsx +11 -2
- package/src/access/PrincipalAvatar.tsx +30 -21
- package/src/access/messages.ts +4 -4
- package/src/account/EntitySearch.tsx +12 -0
- package/src/ai/dictation-runtime.ts +6 -2
- package/src/ai/executor.ts +9 -3
- package/src/ai/index.ts +1 -0
- package/src/ai/project-tool.ts +4 -4
- package/src/ai/projects-authorization.ts +53 -0
- package/src/ai/projects-routes.ts +56 -46
- package/src/ai/projects.ts +100 -79
- package/src/ai/recent-resources.ts +3 -3
- package/src/ai/routes.ts +30 -30
- package/src/ai/skill-tool.ts +4 -4
- package/src/ai/skills-routes.ts +27 -26
- package/src/ai/skills.ts +120 -81
- package/src/api/accounts-entities.ts +6 -3
- package/src/api/admin-ai-skills.ts +4 -1
- package/src/api/auth.ts +9 -2
- package/src/browser/session.ts +39 -0
- package/src/browser/shared-text-history-validation.ts +151 -0
- package/src/browser/shared-text-history.ts +464 -0
- package/src/browser/shared-text-offline-authorized.ts +29 -0
- package/src/browser/shared-text-offline.ts +351 -0
- package/src/browser/shared-text-provider.ts +714 -0
- package/src/browser/shared-text-table-contract.ts +150 -0
- package/src/browser/shared-text.ts +19 -0
- package/src/contracts/shared.ts +2 -1
- package/src/server/api/respond.ts +10 -2
- package/src/server/index.ts +1 -0
- package/src/server/services/access.ts +23 -10
- package/src/server/services/index.ts +1 -0
- package/src/services/accounts/app.ts +30 -13
- package/src/services/accounts/entities.ts +29 -16
- package/src/services/accounts/group-sql.ts +23 -0
- package/src/services/accounts/groups.ts +16 -13
- package/src/services/accounts/identities.ts +8 -1
- package/src/services/accounts/identity-reconciliation.ts +3 -1
- package/src/services/accounts/local-groups.ts +69 -4
- package/src/services/accounts/users.ts +38 -8
- package/src/services/mandates/index.ts +2 -2
- package/src/services/mandates/policy.ts +1 -0
- package/src/services/session/index.ts +12 -3
- package/src/shared/email-html.ts +1 -0
- package/src/shared/markdown/extensions/katex.ts +8 -1
- package/src/shared/markdown/extensions/tables.ts +14 -1
- package/src/shared/markdown/index.ts +22 -11
- package/src/shared-text/anchors.ts +29 -0
- package/src/shared-text/awareness.ts +87 -0
- package/src/shared-text/changes.ts +27 -0
- package/src/shared-text/documents.ts +428 -77
- package/src/shared-text/events.ts +12 -2
- package/src/shared-text/index.ts +16 -1
- package/src/shared-text/log.ts +24 -5
- package/src/shared-text/snapshots.ts +69 -23
- package/src/shared-text/structural-history.ts +377 -0
- package/src/shared-text/table-actions.ts +432 -0
- package/src/shared-text/types.ts +24 -5
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/CloudShell.island.tsx +19 -3
- package/src/ssr/Layout.tsx +1 -1
- package/src/ssr/MobileProfileActions.tsx +2 -2
- package/src/ssr/ProfilePreferences.tsx +2 -1
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
- package/src/ssr/admin-navigation.ts +1 -11
- package/src/ssr/app-launchpad.tsx +6 -1
- package/src/ssr/platform-messages.ts +2 -0
- package/src/ssr/profile-actions.ts +3 -6
- package/src/ssr/shell-data.tsx +1 -1
- package/src/workflows/language/expressions.ts +3 -2
- 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.
|
|
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.
|
|
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 {
|
|
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:
|
|
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.
|
|
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
|
-
{(
|
|
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={
|
|
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
|
|
6
|
-
//
|
|
7
|
-
const hues = { group: "#22c55e",
|
|
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 === "
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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) => (
|
package/src/access/messages.ts
CHANGED
|
@@ -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
|
|
51
|
-
: `${count.toLocaleString("en")}
|
|
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
|
|
141
|
-
: `Über diese Gruppe erhalten gerade ${count.toLocaleString("de")}
|
|
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
|
|
89
|
-
|
|
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,
|
package/src/ai/executor.ts
CHANGED
|
@@ -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 =
|
|
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 ?
|
|
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") ?
|
|
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
package/src/ai/project-tool.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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
|
+
};
|