@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/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 {
|
|
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
|
|
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
|
|
204
|
-
subject
|
|
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 = (
|
|
209
|
-
subject
|
|
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 = {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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:
|
|
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:
|
|
257
|
-
SELECT
|
|
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:
|
|
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:
|
|
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:
|
|
301
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
424
|
-
SELECT access.id
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
443
|
-
SELECT skill_access.access_id
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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 (
|
|
625
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
1074
|
+
subject: AiResourceContext | null,
|
|
1036
1075
|
enabled: boolean,
|
|
1037
1076
|
authority: RequestAuthority | null = null,
|
|
1038
1077
|
): Promise<boolean | null> {
|
|
1039
|
-
if (subject
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|