@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
|
@@ -31,8 +31,6 @@ const listCanonical = async (params: {
|
|
|
31
31
|
scope?: GroupListScope;
|
|
32
32
|
search?: string;
|
|
33
33
|
provider?: UserProvider;
|
|
34
|
-
/** Personal Linux groups are left out of browsing and search unless requested; an explicit `ids` lookup always returns them. */
|
|
35
|
-
includePersonal?: boolean;
|
|
36
34
|
page?: number;
|
|
37
35
|
perPage?: number;
|
|
38
36
|
}): Promise<{
|
|
@@ -48,7 +46,6 @@ const listCanonical = async (params: {
|
|
|
48
46
|
const scope = params.scope ?? (params.userId ? "member" : "all");
|
|
49
47
|
const scopeUserId = params.userId ?? "00000000-0000-0000-0000-000000000000";
|
|
50
48
|
const idsCondition = ids.length === 0 ? sql`TRUE` : sql`g.id = ANY(${toPgUuidArray(ids)}::uuid[])`;
|
|
51
|
-
const includePersonal = params.includePersonal === true || params.ids !== undefined;
|
|
52
49
|
|
|
53
50
|
if (params.ids && params.ids.length === 0) {
|
|
54
51
|
return {
|
|
@@ -69,7 +66,7 @@ const listCanonical = async (params: {
|
|
|
69
66
|
${personalOwnerJoin()}
|
|
70
67
|
WHERE (${params.provider ?? null}::text IS NULL OR g.provider = ${params.provider ?? null})
|
|
71
68
|
AND ${idsCondition}
|
|
72
|
-
AND (
|
|
69
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
73
70
|
AND (
|
|
74
71
|
${scope === "all"} = true
|
|
75
72
|
OR ${params.userId ?? null}::uuid IS NULL
|
|
@@ -107,8 +104,6 @@ export const list = async (params: {
|
|
|
107
104
|
scope?: GroupListScope;
|
|
108
105
|
search?: string;
|
|
109
106
|
provider?: UserProvider;
|
|
110
|
-
/** Personal Linux groups are left out of browsing and search unless requested; an explicit `ids` lookup always returns them. */
|
|
111
|
-
includePersonal?: boolean;
|
|
112
107
|
page?: number;
|
|
113
108
|
perPage?: number;
|
|
114
109
|
}) => {
|
|
@@ -122,13 +117,13 @@ export const get = async (params: { id: string }): Promise<BaseGroup | null> =>
|
|
|
122
117
|
export const getMembers = async (params: {
|
|
123
118
|
id: string;
|
|
124
119
|
provider?: UserProvider;
|
|
125
|
-
type?: "
|
|
120
|
+
type?: GroupMember["type"];
|
|
126
121
|
recursive?: boolean;
|
|
127
122
|
}): Promise<GroupMember[]> => {
|
|
128
123
|
const provider = params.provider ?? (await getGroup(params.id))?.provider;
|
|
129
124
|
if (provider === "local") return localGroups.getMembers(params);
|
|
130
|
-
if (!provider) return [];
|
|
131
|
-
return providers.ipa.groups.getMembers(params);
|
|
125
|
+
if (!provider || params.type === "service_account") return [];
|
|
126
|
+
return providers.ipa.groups.getMembers({ ...params, type: params.type });
|
|
132
127
|
};
|
|
133
128
|
|
|
134
129
|
export const getManagers = async (params: {
|
|
@@ -225,9 +220,13 @@ export const addMember = async (params: {
|
|
|
225
220
|
provider?: UserProvider;
|
|
226
221
|
user?: string;
|
|
227
222
|
group?: string;
|
|
223
|
+
serviceAccount?: string;
|
|
228
224
|
}): Promise<MutationResult<void>> => {
|
|
229
|
-
const provider =
|
|
230
|
-
if (provider === "local")
|
|
225
|
+
const provider = (await getGroup(params.id))?.provider;
|
|
226
|
+
if (provider === "local")
|
|
227
|
+
return localGroups.addMember({ id: params.id, user: params.user, group: params.group, serviceAccount: params.serviceAccount });
|
|
228
|
+
if (params.serviceAccount)
|
|
229
|
+
return { ok: false, error: "Service accounts can only join local Cloud groups, not directory groups", status: 400 };
|
|
231
230
|
const serviceSession = await getServiceIpaSession();
|
|
232
231
|
if (!serviceSession.ok) return serviceSession;
|
|
233
232
|
return providers.ipa.groups.addMember({
|
|
@@ -243,9 +242,13 @@ export const removeMember = async (params: {
|
|
|
243
242
|
provider?: UserProvider;
|
|
244
243
|
user?: string;
|
|
245
244
|
group?: string;
|
|
245
|
+
serviceAccount?: string;
|
|
246
246
|
}): Promise<MutationResult<void>> => {
|
|
247
|
-
const provider =
|
|
248
|
-
if (provider === "local")
|
|
247
|
+
const provider = (await getGroup(params.id))?.provider;
|
|
248
|
+
if (provider === "local")
|
|
249
|
+
return localGroups.removeMember({ id: params.id, user: params.user, group: params.group, serviceAccount: params.serviceAccount });
|
|
250
|
+
if (params.serviceAccount)
|
|
251
|
+
return { ok: false, error: "Service accounts can only join local Cloud groups, not directory groups", status: 400 };
|
|
249
252
|
const serviceSession = await getServiceIpaSession();
|
|
250
253
|
if (!serviceSession.ok) return serviceSession;
|
|
251
254
|
return providers.ipa.groups.removeMember({
|
|
@@ -189,18 +189,25 @@ export const createAccountIdentityService = (db: typeof sql = sql, upstreamIdent
|
|
|
189
189
|
eligible: current.profile === "user",
|
|
190
190
|
};
|
|
191
191
|
}
|
|
192
|
+
const [ownership] = await db<{ personal: boolean }[]>`SELECT EXISTS (
|
|
193
|
+
SELECT 1 FROM auth.personal_groups p
|
|
194
|
+
WHERE (${input.identityId ?? null}::uuid IS NOT NULL AND p.group_id = ${input.identityId ?? null}::uuid)
|
|
195
|
+
OR (${input.identityId ?? null}::uuid IS NULL AND p.group_name = ${input.name})
|
|
196
|
+
) AS personal`;
|
|
197
|
+
const personal = ownership?.personal === true;
|
|
192
198
|
const rows = await groupRows(sql`(g.provider = 'local' AND g.name = ${input.name}) OR g.id = ${input.identityId ?? null}::uuid`);
|
|
193
199
|
const expected = rows.find((row) => row.id === input.identityId);
|
|
194
200
|
if (expected && (expected.provider !== "local" || expected.name !== input.name))
|
|
195
201
|
return { state: "unknown", reason: "identity_conflict" };
|
|
196
202
|
const matches = rows.filter((row) => row.provider === "local" && row.name === input.name);
|
|
197
|
-
if (!matches.length) return { state: "absent" };
|
|
203
|
+
if (!matches.length) return personal ? { state: "absent", personal: true } : { state: "absent" };
|
|
198
204
|
if (matches.length !== 1) return { state: "unknown", reason: "invalid_response" };
|
|
199
205
|
const current = matches[0]!;
|
|
200
206
|
return {
|
|
201
207
|
state: "present",
|
|
202
208
|
identity: { id: current.id, name: current.name, uidNumber: null, gidNumber: current.gidNumber },
|
|
203
209
|
eligible: current.gidNumber !== null,
|
|
210
|
+
...(personal ? { personal: true } : {}),
|
|
204
211
|
};
|
|
205
212
|
};
|
|
206
213
|
const validateLookup = (input: { kind: "users" | "groups"; name: string }) => {
|
|
@@ -9,8 +9,10 @@ export type AccountIdentityReconciliation =
|
|
|
9
9
|
state: "present";
|
|
10
10
|
identity: { id: string | null; name: string; uidNumber: number | null; gidNumber: number | null };
|
|
11
11
|
eligible: boolean;
|
|
12
|
+
/** Proven Cloud personal-group ownership, including retained directory bindings. */
|
|
13
|
+
personal?: boolean;
|
|
12
14
|
}
|
|
13
|
-
| { state: "absent" }
|
|
15
|
+
| { state: "absent"; personal?: boolean }
|
|
14
16
|
| { state: "unknown"; reason: "provider_disabled" | "provider_unavailable" | "invalid_response" | "identity_conflict" };
|
|
15
17
|
export type IdentityLookup = { kind: "users" | "groups"; name: string; signal?: AbortSignal };
|
|
16
18
|
|
|
@@ -2,6 +2,7 @@ import { sql } from "bun";
|
|
|
2
2
|
import type { BaseGroup, GroupMember, MutationResult, UserProvider } from "../../contracts/shared";
|
|
3
3
|
import { escapeLikePattern, isUniqueViolation } from "../postgres";
|
|
4
4
|
import { buildBaseGroup, personalOwnerJoin } from "./base-group";
|
|
5
|
+
import { recursiveServiceAccountGroupIdsSubquery } from "./group-sql";
|
|
5
6
|
|
|
6
7
|
type DbRow = Record<string, unknown>;
|
|
7
8
|
|
|
@@ -122,6 +123,7 @@ export const list = async (params: { page?: number; perPage?: number; search?: s
|
|
|
122
123
|
SELECT COUNT(*)::int AS count
|
|
123
124
|
FROM auth.groups g
|
|
124
125
|
WHERE g.provider = 'local'
|
|
126
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
125
127
|
AND (${pattern}::text IS NULL OR LOWER(g.name) LIKE ${pattern} ESCAPE '\\' OR LOWER(g.description) LIKE ${pattern} ESCAPE '\\')
|
|
126
128
|
`;
|
|
127
129
|
const total = Number(countRow?.count ?? 0);
|
|
@@ -130,6 +132,7 @@ export const list = async (params: { page?: number; perPage?: number; search?: s
|
|
|
130
132
|
FROM auth.groups g
|
|
131
133
|
${personalOwnerJoin()}
|
|
132
134
|
WHERE g.provider = 'local'
|
|
135
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
133
136
|
AND (${pattern}::text IS NULL OR LOWER(g.name) LIKE ${pattern} ESCAPE '\\' OR LOWER(g.description) LIKE ${pattern} ESCAPE '\\')
|
|
134
137
|
ORDER BY g.name
|
|
135
138
|
LIMIT ${perPage} OFFSET ${offset}
|
|
@@ -177,7 +180,7 @@ export const remove = async (params: { id: string }): Promise<MutationResult<voi
|
|
|
177
180
|
return { ok: true, data: undefined };
|
|
178
181
|
};
|
|
179
182
|
|
|
180
|
-
export const getMembers = async (params: { id: string; type?: "
|
|
183
|
+
export const getMembers = async (params: { id: string; type?: GroupMember["type"]; recursive?: boolean }): Promise<GroupMember[]> => {
|
|
181
184
|
const group = await getLocalGroupById(params.id);
|
|
182
185
|
if (!group) return [];
|
|
183
186
|
const members: GroupMember[] = [];
|
|
@@ -231,6 +234,7 @@ export const getMembers = async (params: { id: string; type?: "user" | "group";
|
|
|
231
234
|
SELECT DISTINCT g.id, g.name
|
|
232
235
|
FROM local_group_tree tree
|
|
233
236
|
JOIN auth.groups g ON g.id = tree.child_group_id
|
|
237
|
+
WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
234
238
|
ORDER BY g.name
|
|
235
239
|
`
|
|
236
240
|
: await sql<DbRow[]>`
|
|
@@ -238,6 +242,7 @@ export const getMembers = async (params: { id: string; type?: "user" | "group";
|
|
|
238
242
|
FROM auth.group_groups_v2 gg
|
|
239
243
|
JOIN auth.groups g ON g.id = gg.child_group_id
|
|
240
244
|
WHERE gg.parent_group_id = ${group.id} AND g.provider = 'local'
|
|
245
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
241
246
|
ORDER BY g.name
|
|
242
247
|
`;
|
|
243
248
|
for (const row of groupRows) {
|
|
@@ -245,6 +250,25 @@ export const getMembers = async (params: { id: string; type?: "user" | "group";
|
|
|
245
250
|
}
|
|
246
251
|
}
|
|
247
252
|
|
|
253
|
+
if (!params.type || params.type === "service_account") {
|
|
254
|
+
const rows = await sql<{ id: string; name: string; kind: "standalone" | "agent" }[]>`
|
|
255
|
+
SELECT sa.id, sa.name, sa.kind
|
|
256
|
+
FROM auth.service_accounts sa
|
|
257
|
+
WHERE sa.kind IN ('standalone', 'agent') AND ${
|
|
258
|
+
params.recursive === true
|
|
259
|
+
? sql`${group.id}::uuid IN (${recursiveServiceAccountGroupIdsSubquery(sql`sa.id`)})`
|
|
260
|
+
: sql`EXISTS (
|
|
261
|
+
SELECT 1 FROM auth.service_account_groups sg
|
|
262
|
+
WHERE sg.service_account_id = sa.id AND sg.group_id = ${group.id}::uuid
|
|
263
|
+
)`
|
|
264
|
+
}
|
|
265
|
+
ORDER BY sa.name, sa.id
|
|
266
|
+
`;
|
|
267
|
+
members.push(
|
|
268
|
+
...rows.map((row) => ({ type: "service_account" as const, id: row.id, displayName: row.name, serviceAccountKind: row.kind })),
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
248
272
|
return members;
|
|
249
273
|
};
|
|
250
274
|
|
|
@@ -310,6 +334,7 @@ export const getManagers = async (params: { id: string; type?: "user" | "group";
|
|
|
310
334
|
FROM auth.group_manager_groups_v2 gmg
|
|
311
335
|
JOIN auth.groups g ON g.id = gmg.manager_group_id
|
|
312
336
|
WHERE gmg.group_id = ${group.id} AND g.provider = 'local'
|
|
337
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
313
338
|
ORDER BY g.name
|
|
314
339
|
`;
|
|
315
340
|
for (const row of groupRows) {
|
|
@@ -340,7 +365,8 @@ export const getParents = async (params: { id: string; recursive?: boolean }): P
|
|
|
340
365
|
WHERE g.provider = 'local'
|
|
341
366
|
)
|
|
342
367
|
SELECT DISTINCT parent_group_id
|
|
343
|
-
FROM local_parent_tree
|
|
368
|
+
FROM local_parent_tree tree
|
|
369
|
+
WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = tree.parent_group_id)
|
|
344
370
|
`
|
|
345
371
|
: await sql<DbRow[]>`
|
|
346
372
|
SELECT gg.parent_group_id
|
|
@@ -348,6 +374,7 @@ export const getParents = async (params: { id: string; recursive?: boolean }): P
|
|
|
348
374
|
JOIN auth.groups g ON g.id = gg.parent_group_id
|
|
349
375
|
WHERE gg.child_group_id = ${group.id}
|
|
350
376
|
AND g.provider = 'local'
|
|
377
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
351
378
|
ORDER BY g.name
|
|
352
379
|
`;
|
|
353
380
|
|
|
@@ -374,6 +401,7 @@ export const getManagedGroups = async (params: { id: string; recursive?: boolean
|
|
|
374
401
|
JOIN auth.group_manager_groups_v2 gmg ON gmg.manager_group_id = tree.manager_group_id
|
|
375
402
|
JOIN auth.groups g ON g.id = gmg.group_id
|
|
376
403
|
WHERE g.provider = 'local'
|
|
404
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
377
405
|
`
|
|
378
406
|
: await sql<DbRow[]>`
|
|
379
407
|
SELECT gmg.group_id
|
|
@@ -381,15 +409,38 @@ export const getManagedGroups = async (params: { id: string; recursive?: boolean
|
|
|
381
409
|
JOIN auth.groups g ON g.id = gmg.group_id
|
|
382
410
|
WHERE gmg.manager_group_id = ${group.id}
|
|
383
411
|
AND g.provider = 'local'
|
|
412
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
384
413
|
ORDER BY g.name
|
|
385
414
|
`;
|
|
386
415
|
|
|
387
416
|
return rows.map((row) => row.group_id as string);
|
|
388
417
|
};
|
|
389
418
|
|
|
390
|
-
export const addMember = async (params: {
|
|
419
|
+
export const addMember = async (params: {
|
|
420
|
+
id: string;
|
|
421
|
+
user?: string;
|
|
422
|
+
group?: string;
|
|
423
|
+
serviceAccount?: string;
|
|
424
|
+
}): Promise<MutationResult<void>> => {
|
|
391
425
|
const group = await getLocalGroupById(params.id);
|
|
392
426
|
if (!group) return { ok: false, error: "Group not found", status: 404 };
|
|
427
|
+
const [personal] = await sql`SELECT 1 FROM auth.personal_groups
|
|
428
|
+
WHERE group_id = ${group.id}::uuid OR group_id = ${params.group ?? null}::uuid`;
|
|
429
|
+
if (personal) return { ok: false, error: "Personal Linux groups cannot be used in group relationships", status: 409 };
|
|
430
|
+
|
|
431
|
+
if (params.serviceAccount) {
|
|
432
|
+
const [account] = await sql<{ id: string }[]>`
|
|
433
|
+
SELECT id FROM auth.service_accounts
|
|
434
|
+
WHERE id = ${params.serviceAccount}::uuid AND status = 'active' AND kind IN ('standalone', 'agent')
|
|
435
|
+
`;
|
|
436
|
+
if (!account) return { ok: false, error: "An active standalone or agent account is required", status: 400 };
|
|
437
|
+
const inserted = await sql`
|
|
438
|
+
INSERT INTO auth.service_account_groups (service_account_id, group_id)
|
|
439
|
+
VALUES (${account.id}::uuid, ${group.id}::uuid) ON CONFLICT DO NOTHING
|
|
440
|
+
`;
|
|
441
|
+
if (inserted.count === 0) return { ok: false, error: "Service account is already a direct member of this group", status: 409 };
|
|
442
|
+
return { ok: true, data: undefined };
|
|
443
|
+
}
|
|
393
444
|
|
|
394
445
|
if (params.user) {
|
|
395
446
|
const provider = await getUserProvider(params.user);
|
|
@@ -427,10 +478,21 @@ export const addMember = async (params: { id: string; user?: string; group?: str
|
|
|
427
478
|
return { ok: false, error: "Missing member", status: 400 };
|
|
428
479
|
};
|
|
429
480
|
|
|
430
|
-
export const removeMember = async (params: {
|
|
481
|
+
export const removeMember = async (params: {
|
|
482
|
+
id: string;
|
|
483
|
+
user?: string;
|
|
484
|
+
group?: string;
|
|
485
|
+
serviceAccount?: string;
|
|
486
|
+
}): Promise<MutationResult<void>> => {
|
|
431
487
|
const group = await getLocalGroupById(params.id);
|
|
432
488
|
if (!group) return { ok: false, error: "Group not found", status: 404 };
|
|
433
489
|
|
|
490
|
+
if (params.serviceAccount) {
|
|
491
|
+
await sql`DELETE FROM auth.service_account_groups
|
|
492
|
+
WHERE service_account_id = ${params.serviceAccount}::uuid AND group_id = ${group.id}::uuid`;
|
|
493
|
+
return { ok: true, data: undefined };
|
|
494
|
+
}
|
|
495
|
+
|
|
434
496
|
if (params.user) {
|
|
435
497
|
await sql`DELETE FROM auth.user_groups_v2 WHERE user_id = ${params.user}::uuid AND group_id = ${group.id}::uuid`;
|
|
436
498
|
return { ok: true, data: undefined };
|
|
@@ -447,6 +509,9 @@ export const removeMember = async (params: { id: string; user?: string; group?:
|
|
|
447
509
|
export const addManager = async (params: { id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
|
|
448
510
|
const group = await getLocalGroupById(params.id);
|
|
449
511
|
if (!group) return { ok: false, error: "Group not found", status: 404 };
|
|
512
|
+
const [personal] = await sql`SELECT 1 FROM auth.personal_groups
|
|
513
|
+
WHERE group_id = ${group.id}::uuid OR group_id = ${params.group ?? null}::uuid`;
|
|
514
|
+
if (personal) return { ok: false, error: "Personal Linux groups cannot be used in group relationships", status: 409 };
|
|
450
515
|
|
|
451
516
|
if (params.user) {
|
|
452
517
|
const provider = await getUserProvider(params.user);
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
BaseUser,
|
|
4
|
+
MutationResult,
|
|
5
|
+
PaginationResponse,
|
|
6
|
+
RequestActor,
|
|
7
|
+
Role,
|
|
8
|
+
User,
|
|
9
|
+
UserProfile,
|
|
10
|
+
UserProvider,
|
|
11
|
+
} from "../../contracts/shared";
|
|
3
12
|
import { freeipa } from "../../server/services";
|
|
4
13
|
import { createAuthLoginUrl } from "../../shared/redirect";
|
|
5
14
|
import { isAccountCategoryAllowed } from "../account-category-policy";
|
|
@@ -225,9 +234,10 @@ export const getMinimal = async (params: { id: string } | { uid: string }): Prom
|
|
|
225
234
|
};
|
|
226
235
|
|
|
227
236
|
/** Audience roles for a resolved access subject, including delegated users and group managers. */
|
|
228
|
-
export const getRoles = async (userId: string): Promise<Role[]> => {
|
|
229
|
-
const
|
|
230
|
-
const
|
|
237
|
+
export const getRoles = async (userId: string, options?: { db: typeof sql }): Promise<Role[]> => {
|
|
238
|
+
const db = options?.db ?? sql;
|
|
239
|
+
const groupsAdmin = options ? await getFreeIpaAdminGroups(db) : (await getFreeIpaConfig()).groupsAdmin;
|
|
240
|
+
const [row] = await db<DbRow[]>`SELECT u.provider, u.profile,
|
|
231
241
|
CASE WHEN u.provider='local' THEN u.admin ELSE EXISTS (
|
|
232
242
|
SELECT 1 FROM auth.ipa_user_effective_groups eg WHERE eg.user_id=u.id
|
|
233
243
|
AND eg.group_name=ANY(${toPgTextArray(groupsAdmin)}::text[])
|
|
@@ -239,6 +249,26 @@ export const getRoles = async (userId: string): Promise<Role[]> => {
|
|
|
239
249
|
return buildRoles({ provider, profile, memberofGroup: [], manages: stringArray(row.manages), admin: Boolean(row.effective_admin) });
|
|
240
250
|
};
|
|
241
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Revalidate installation authority and retain it until the caller commits.
|
|
254
|
+
* Lock users before grant/membership or mandate/profile locks, matching user
|
|
255
|
+
* deletion and provider sync. Projection and settings locks also cover rows
|
|
256
|
+
* inserted or replaced while lock acquisition was waiting.
|
|
257
|
+
*/
|
|
258
|
+
export const hasInstallationAdminInTransaction = async (actor: RequestActor, db: typeof sql): Promise<boolean> => {
|
|
259
|
+
if (actor.kind !== "user" || actor.sessionKind === "app" || !actor.user.roles.includes("admin")) return false;
|
|
260
|
+
const [user] = await db<{ provider: UserProvider; profile: UserProfile; expired: boolean }[]>`
|
|
261
|
+
SELECT provider, profile, (account_expires IS NOT NULL AND account_expires <= statement_timestamp()) AS expired
|
|
262
|
+
FROM auth.users WHERE id=${actor.user.id}::uuid FOR SHARE`;
|
|
263
|
+
if (!user || user.expired || user.profile !== "user") return false;
|
|
264
|
+
await db`LOCK TABLE settings.entries IN SHARE MODE`;
|
|
265
|
+
if (!(await isAccountCategoryAllowed(user, db))) return false;
|
|
266
|
+
if (user.provider === "ipa") {
|
|
267
|
+
await db`LOCK TABLE auth.ipa_user_effective_groups IN SHARE MODE`;
|
|
268
|
+
}
|
|
269
|
+
return (await getRoles(actor.user.id, { db })).includes("admin");
|
|
270
|
+
};
|
|
271
|
+
|
|
242
272
|
export const getByUid = async (params: { uid: string }): Promise<{ id: string; roles: Role[] } | null> => {
|
|
243
273
|
const { groupsAdmin } = await getFreeIpaConfig();
|
|
244
274
|
const rows = await sql<DbRow[]>`
|
|
@@ -347,15 +377,15 @@ export const list = async (params: {
|
|
|
347
377
|
};
|
|
348
378
|
};
|
|
349
379
|
|
|
350
|
-
/**
|
|
351
|
-
export const getGroups = async (params: { id: string; recursive?: boolean
|
|
380
|
+
/** Exported group names exclude personal groups; authorization uses the complete group ID projection. */
|
|
381
|
+
export const getGroups = async (params: { id: string; recursive?: boolean }): Promise<string[]> => {
|
|
352
382
|
if (params.recursive) {
|
|
353
383
|
const rows = await sql<DbRow[]>`${recursiveUserGroupsSubquery({
|
|
354
384
|
userId: params.id,
|
|
355
385
|
select: sql`
|
|
356
386
|
SELECT DISTINCT g.name FROM user_all_groups ag
|
|
357
387
|
JOIN auth.groups g ON g.id = ag.group_id
|
|
358
|
-
WHERE
|
|
388
|
+
WHERE NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
359
389
|
ORDER BY g.name
|
|
360
390
|
`,
|
|
361
391
|
})}`;
|
|
@@ -364,7 +394,7 @@ export const getGroups = async (params: { id: string; recursive?: boolean; inclu
|
|
|
364
394
|
const rows = await sql<DbRow[]>`
|
|
365
395
|
SELECT g.name FROM auth.groups g
|
|
366
396
|
WHERE g.id IN (SELECT group_id FROM auth.user_groups_v2 WHERE user_id = ${params.id}::uuid)
|
|
367
|
-
AND
|
|
397
|
+
AND NOT EXISTS (SELECT 1 FROM auth.personal_groups p WHERE p.group_id = g.id)
|
|
368
398
|
ORDER BY g.name
|
|
369
399
|
`;
|
|
370
400
|
return rows.map((row) => row.name as string);
|
|
@@ -207,7 +207,7 @@ const validSubject = async (subject: MandateSubject, db: SQL): Promise<boolean>
|
|
|
207
207
|
SELECT EXISTS (
|
|
208
208
|
SELECT 1 FROM auth.service_accounts
|
|
209
209
|
WHERE id = ${subject.id}::uuid
|
|
210
|
-
AND kind
|
|
210
|
+
AND kind IN ('resource_bound', 'standalone', 'agent')
|
|
211
211
|
AND status = 'active'
|
|
212
212
|
) AS active
|
|
213
213
|
`;
|
|
@@ -687,7 +687,7 @@ export const validateMandateIssueAuthority = async (
|
|
|
687
687
|
WHEN 'user' THEN user_account.id IS NOT NULL
|
|
688
688
|
AND (user_account.account_expires IS NULL OR user_account.account_expires > now())
|
|
689
689
|
WHEN 'service_account' THEN service_account.id IS NOT NULL
|
|
690
|
-
AND service_account.kind
|
|
690
|
+
AND service_account.kind IN ('resource_bound', 'standalone', 'agent')
|
|
691
691
|
AND service_account.status = 'active'
|
|
692
692
|
ELSE false
|
|
693
693
|
END AS subject_active
|
|
@@ -648,11 +648,20 @@ export const session = {
|
|
|
648
648
|
|
|
649
649
|
revoke: revokeToken,
|
|
650
650
|
|
|
651
|
-
/** Signs out the web session (or an explicit bearer
|
|
652
|
-
delete: async (c: Context): Promise<
|
|
651
|
+
/** Signs out the web session (or an explicit bearer). False when an expected account no longer owns the request's credential. */
|
|
652
|
+
delete: async (c: Context, expectedUserId?: string | null): Promise<boolean> => {
|
|
653
|
+
// Capture once: another tab may replace the browser cookie while this
|
|
654
|
+
// request runs, but both the comparison and revocation concern this token.
|
|
653
655
|
const token = getWebOrBearerToken(c);
|
|
656
|
+
if (token && expectedUserId !== undefined) {
|
|
657
|
+
const claims = await verifySessionToken(token);
|
|
658
|
+
if (claims && claims.sub !== expectedUserId) return false;
|
|
659
|
+
}
|
|
654
660
|
if (token) await revokeToken(token, c.req.header("x-request-id"));
|
|
655
|
-
|
|
661
|
+
// Bound browser logout revokes the captured family without mutating the
|
|
662
|
+
// shared cookie: its response may arrive after another tab signs in.
|
|
663
|
+
if (expectedUserId === undefined) deleteCookie(c, "session_token", { path: "/" });
|
|
664
|
+
return true;
|
|
656
665
|
},
|
|
657
666
|
|
|
658
667
|
issueInTransaction,
|
package/src/shared/email-html.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import katex from "katex";
|
|
10
10
|
import type { MarkedExtension, Tokens } from "marked";
|
|
11
|
+
import { escapeHtml } from "../shared";
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Token shape produced by the math tokenizers below. The marked extension API
|
|
@@ -40,7 +41,7 @@ function renderInlineMath(latex: string): string {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
export function katexExtension(): MarkedExtension {
|
|
44
|
+
export function katexExtension(literal = false): MarkedExtension {
|
|
44
45
|
return {
|
|
45
46
|
extensions: [
|
|
46
47
|
// ```math code blocks
|
|
@@ -63,6 +64,10 @@ export function katexExtension(): MarkedExtension {
|
|
|
63
64
|
return undefined;
|
|
64
65
|
},
|
|
65
66
|
renderer(token: Tokens.Generic) {
|
|
67
|
+
if (literal) {
|
|
68
|
+
const source = token.raw.slice("```math\n".length, -"\n```".length);
|
|
69
|
+
return `<pre>${escapeHtml(source)}</pre>\n`;
|
|
70
|
+
}
|
|
66
71
|
const latex = (token as MathToken).latex;
|
|
67
72
|
return renderBlockMath(latex);
|
|
68
73
|
},
|
|
@@ -87,6 +92,7 @@ export function katexExtension(): MarkedExtension {
|
|
|
87
92
|
return undefined;
|
|
88
93
|
},
|
|
89
94
|
renderer(token: Tokens.Generic) {
|
|
95
|
+
if (literal) return `<p>${escapeHtml(token.raw)}</p>\n`;
|
|
90
96
|
const latex = (token as MathToken).latex;
|
|
91
97
|
return renderBlockMath(latex);
|
|
92
98
|
},
|
|
@@ -111,6 +117,7 @@ export function katexExtension(): MarkedExtension {
|
|
|
111
117
|
return undefined;
|
|
112
118
|
},
|
|
113
119
|
renderer(token: Tokens.Generic) {
|
|
120
|
+
if (literal) return escapeHtml(token.raw);
|
|
114
121
|
const latex = (token as MathToken).latex;
|
|
115
122
|
return renderInlineMath(latex);
|
|
116
123
|
},
|
|
@@ -66,11 +66,24 @@ const renderCell = (alignCls: string, originalText: string, ctx: EvalContext, ht
|
|
|
66
66
|
return `<td><span class="md-table-cell md-formula-error${alignCls}" title="${escapeHtml(tooltip)}">⚠ ${escapeHtml(originalText)}</span></td>`;
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
-
export function tablesExtension(): MarkedExtension {
|
|
69
|
+
export function tablesExtension(plain = false): MarkedExtension {
|
|
70
70
|
return {
|
|
71
71
|
renderer: {
|
|
72
72
|
table(token: Tokens.Table): string {
|
|
73
73
|
const align = token.align ?? [];
|
|
74
|
+
// Email has no Cloud stylesheet or formula widgets. Keep alignment on the
|
|
75
|
+
// structural cells and escape formula source before Markdown can interpret it.
|
|
76
|
+
if (plain) {
|
|
77
|
+
const cellHtml = (cell: Tokens.TableCell, tag: "th" | "td", column: number): string => {
|
|
78
|
+
const alignment = align[column];
|
|
79
|
+
const attribute = alignment ? ` align="${alignment}"` : "";
|
|
80
|
+
const content = isFormula(cell.text) ? escapeHtml(cell.text) : this.parser.parseInline(cell.tokens);
|
|
81
|
+
return `<${tag}${attribute}>${content}</${tag}>`;
|
|
82
|
+
};
|
|
83
|
+
const header = token.header.map((cell, column) => cellHtml(cell, "th", column)).join("");
|
|
84
|
+
const rows = token.rows.map((row) => `<tr>${row.map((cell, column) => cellHtml(cell, "td", column)).join("")}</tr>`).join("");
|
|
85
|
+
return `<table><thead><tr>${header}</tr></thead><tbody>${rows}</tbody></table>`;
|
|
86
|
+
}
|
|
74
87
|
|
|
75
88
|
// Build the EvalContext once per table — formulas reference
|
|
76
89
|
// raw cell text, not other formulas' results, so a single pass
|
|
@@ -25,8 +25,15 @@ import { escapeHtml } from "./shared";
|
|
|
25
25
|
// Create a configured marked instance
|
|
26
26
|
type MarkdownProfile = "content" | "help";
|
|
27
27
|
type LinkStyle = "widget" | "plain";
|
|
28
|
+
type MarkdownOutput = "cloud" | "email";
|
|
28
29
|
|
|
29
|
-
const createMarked = (
|
|
30
|
+
const createMarked = (
|
|
31
|
+
profile: MarkdownProfile,
|
|
32
|
+
links: LinkStyle,
|
|
33
|
+
infoBlocks: MarkedExtension,
|
|
34
|
+
locale?: string,
|
|
35
|
+
output: MarkdownOutput = "cloud",
|
|
36
|
+
) => {
|
|
30
37
|
const marked = new Marked();
|
|
31
38
|
|
|
32
39
|
marked.use({
|
|
@@ -38,12 +45,12 @@ const createMarked = (profile: MarkdownProfile, links: LinkStyle, infoBlocks: Ma
|
|
|
38
45
|
// Note: katexExtension must come before codeExtension to handle ```math blocks
|
|
39
46
|
marked.use(infoBlocks);
|
|
40
47
|
marked.use(taskListExtension());
|
|
41
|
-
marked.use(tablesExtension());
|
|
48
|
+
marked.use(tablesExtension(output === "email"));
|
|
42
49
|
// Plain output keeps marked's own link renderer and renders code blocks as a bare `pre`.
|
|
43
50
|
// Cloud links are calm reference pills and quiet web links from `@k2b/ui`.
|
|
44
51
|
if (links === "widget") marked.use(linksExtension({ internalTarget: profile === "help" ? "_self" : "_blank", locale }));
|
|
45
52
|
marked.use(imagesExtension());
|
|
46
|
-
marked.use(katexExtension());
|
|
53
|
+
marked.use(katexExtension(output === "email"));
|
|
47
54
|
marked.use(links === "widget" ? codeExtension() : plainCodeExtension());
|
|
48
55
|
// Inline-style decorators come last so they run after structural tokenizers.
|
|
49
56
|
marked.use(markExtension());
|
|
@@ -57,12 +64,12 @@ const createMarked = (profile: MarkdownProfile, links: LinkStyle, infoBlocks: Ma
|
|
|
57
64
|
// headers choose the locale, and `markdownInfoBlocks()` returns one extension
|
|
58
65
|
// for every locale that shares a catalog, so the cache stays bounded.
|
|
59
66
|
const instances = new Map<MarkedExtension, Map<string, Marked>>();
|
|
60
|
-
const markedFor = (profile: MarkdownProfile, links: LinkStyle = "widget", locale?: string): Marked => {
|
|
67
|
+
const markedFor = (profile: MarkdownProfile, links: LinkStyle = "widget", locale?: string, output: MarkdownOutput = "cloud"): Marked => {
|
|
61
68
|
const infoBlocks = markdownInfoBlocks({ locale });
|
|
62
69
|
const byStyle = instances.get(infoBlocks) ?? new Map<string, Marked>();
|
|
63
70
|
instances.set(infoBlocks, byStyle);
|
|
64
|
-
const key = `${profile}:${links}`;
|
|
65
|
-
const instance = byStyle.get(key) ?? createMarked(profile, links, infoBlocks, locale);
|
|
71
|
+
const key = `${profile}:${links}:${output}`;
|
|
72
|
+
const instance = byStyle.get(key) ?? createMarked(profile, links, infoBlocks, locale, output);
|
|
66
73
|
byStyle.set(key, instance);
|
|
67
74
|
return instance;
|
|
68
75
|
};
|
|
@@ -70,18 +77,21 @@ const markedFor = (profile: MarkdownProfile, links: LinkStyle = "widget", locale
|
|
|
70
77
|
const marked = markedFor("content");
|
|
71
78
|
|
|
72
79
|
export type MarkdownRenderOptions = {
|
|
80
|
+
/** `"email"` keeps strikethrough and structural table alignment, and renders table formulas and math as literal source. Defaults to Cloud's styled output with formula evaluation. */
|
|
81
|
+
output?: MarkdownOutput;
|
|
73
82
|
/** `"plain"` renders each link as an ordinary anchor around its text, for HTML read outside Cloud such as email. It also renders a code block, fenced or indented and including a Mermaid fence, as its source in a `pre` without the language label. The default renders references to Cloud content as calm pills and web links as quiet text links. */
|
|
74
83
|
links?: LinkStyle;
|
|
75
84
|
/** Request locale for the screen-reader names of untitled info blocks. Defaults to English on the server and the page language in the browser. */
|
|
76
85
|
locale?: string;
|
|
77
86
|
};
|
|
78
87
|
|
|
79
|
-
const sanitizeRenderedHtml = (html: string, profile: MarkdownProfile = "content"): string => {
|
|
88
|
+
const sanitizeRenderedHtml = (html: string, profile: MarkdownProfile = "content", output: MarkdownOutput = "cloud"): string => {
|
|
80
89
|
const callouts = markdownCalloutSanitizeOptions();
|
|
81
90
|
return sanitizeHtml(html, {
|
|
82
91
|
...callouts,
|
|
83
92
|
allowedTags: [
|
|
84
93
|
...sanitizeHtml.defaults.allowedTags,
|
|
94
|
+
...(output === "email" ? ["del"] : []),
|
|
85
95
|
"annotation",
|
|
86
96
|
"aside",
|
|
87
97
|
"br",
|
|
@@ -137,6 +147,7 @@ const sanitizeRenderedHtml = (html: string, profile: MarkdownProfile = "content"
|
|
|
137
147
|
input: ["checked", "class", "disabled", "type"],
|
|
138
148
|
math: ["xmlns"],
|
|
139
149
|
pre: ["class"],
|
|
150
|
+
...(output === "email" ? { th: ["align"], td: ["align"] } : {}),
|
|
140
151
|
...callouts.allowedAttributes,
|
|
141
152
|
span: ["aria-hidden", "class", "style", "title"],
|
|
142
153
|
},
|
|
@@ -192,10 +203,10 @@ const sanitizeRenderedHtml = (html: string, profile: MarkdownProfile = "content"
|
|
|
192
203
|
export function renderMarkdown(content: string, options: MarkdownRenderOptions = {}): string {
|
|
193
204
|
if (!content || typeof content !== "string") return "";
|
|
194
205
|
|
|
195
|
-
const html = markedFor("content", options.links, options.locale).parse(content);
|
|
206
|
+
const html = markedFor("content", options.links, options.locale, options.output).parse(content);
|
|
196
207
|
if (typeof html !== "string") return "";
|
|
197
208
|
|
|
198
|
-
return sanitizeRenderedHtml(html);
|
|
209
|
+
return sanitizeRenderedHtml(html, "content", options.output);
|
|
199
210
|
}
|
|
200
211
|
|
|
201
212
|
/**
|
|
@@ -204,10 +215,10 @@ export function renderMarkdown(content: string, options: MarkdownRenderOptions =
|
|
|
204
215
|
export function renderMarkdownSync(content: string, options: MarkdownRenderOptions = {}): string {
|
|
205
216
|
if (!content || typeof content !== "string") return "";
|
|
206
217
|
|
|
207
|
-
const html = markedFor("content", options.links, options.locale).parse(content);
|
|
218
|
+
const html = markedFor("content", options.links, options.locale, options.output).parse(content);
|
|
208
219
|
if (typeof html !== "string") return "";
|
|
209
220
|
|
|
210
|
-
return sanitizeRenderedHtml(html);
|
|
221
|
+
return sanitizeRenderedHtml(html, "content", options.output);
|
|
211
222
|
}
|
|
212
223
|
|
|
213
224
|
/**
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
|
|
3
|
+
/** Capture an existing character, never a boundary that could drift to a neighbour. */
|
|
4
|
+
export const captureTextAnchor = (text: Y.Text, index: number): Uint8Array => {
|
|
5
|
+
if (!text.doc || !Number.isSafeInteger(index) || index < 0 || index >= text.length)
|
|
6
|
+
throw new RangeError("A text anchor must identify an existing character in an attached Y.Text");
|
|
7
|
+
return Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(text, index, 0));
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve only the original live character. Ordinary relative positions also
|
|
12
|
+
* resolve beside deleted characters and follow undo replacements; neither is
|
|
13
|
+
* a safe target for a delayed structural action.
|
|
14
|
+
*/
|
|
15
|
+
export const resolveTextAnchor = (text: Y.Text, bytes: Uint8Array): number | null => {
|
|
16
|
+
if (!text.doc) return null;
|
|
17
|
+
try {
|
|
18
|
+
const anchor = Y.decodeRelativePosition(bytes);
|
|
19
|
+
if (!anchor.item || anchor.assoc !== 0) return null;
|
|
20
|
+
const position = Y.createAbsolutePositionFromRelativePosition(anchor, text.doc, false);
|
|
21
|
+
if (!position || position.type !== text || position.index < 0 || position.index >= text.length) return null;
|
|
22
|
+
// Item encodings omit the root type name. Compare two decoded positions,
|
|
23
|
+
// rather than a decoded anchor with a fresh position containing that name.
|
|
24
|
+
const live = Y.decodeRelativePosition(captureTextAnchor(text, position.index));
|
|
25
|
+
return Y.compareRelativePositions(anchor, live) ? position.index : null;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
};
|