@k2b/cloud 0.45.0 → 0.46.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 +5 -2
- package/src/_internal/app-queries.ts +73 -0
- package/src/_internal/capabilities.ts +137 -9
- package/src/_internal/define-app.ts +2 -0
- package/src/_internal/registry-validation.ts +17 -4
- package/src/_internal/registry.ts +32 -4
- package/src/_internal/resource-principals.ts +36 -0
- package/src/ai/capabilities.ts +4 -2
- package/src/ai/chat/map-messages.ts +270 -0
- package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
- package/src/ai/chat/map-notebook.tsx +301 -0
- package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
- package/src/ai/chat/map-widget.css +107 -0
- package/src/ai/chat/map-widget.tsx +1279 -0
- package/src/ai/chat/message-utils.ts +1 -0
- package/src/ai/chat/tool-names.ts +2 -0
- package/src/ai/chat/turn-layout.ts +2 -1
- package/src/ai/chat/turn-view.tsx +17 -1
- package/src/ai/default-tools.ts +3 -0
- package/src/ai/executor.ts +13 -1
- package/src/ai/map-contracts.ts +209 -0
- package/src/ai/map-execution.ts +14 -0
- package/src/ai/map-notebook-save.ts +276 -0
- package/src/ai/map-notebook.ts +32 -0
- package/src/ai/map-opening-hours.ts +40 -0
- package/src/ai/map-routes.ts +84 -0
- package/src/ai/map-service.ts +288 -0
- package/src/ai/map-store.ts +137 -0
- package/src/ai/map-tool.ts +73 -0
- package/src/ai/migrate.ts +3 -0
- package/src/ai/protocol.ts +3 -1
- package/src/ai/routes.ts +2 -0
- package/src/ai/store.ts +19 -6
- package/src/ai/tools.ts +14 -0
- package/src/ai/types.ts +3 -0
- package/src/api/capabilities.ts +90 -12
- package/src/api/mcp.ts +4 -2
- package/src/browser/notifications.ts +25 -8
- package/src/capabilities/discussion-providers.ts +99 -0
- package/src/capabilities/executions.ts +2 -2
- package/src/capabilities/migrate.ts +10 -1
- package/src/capabilities/resource-readers.ts +58 -0
- package/src/capabilities/server-invoke.ts +23 -13
- package/src/capabilities/server.ts +17 -6
- package/src/capabilities/testing.ts +13 -0
- package/src/contracts/capabilities.ts +137 -4
- package/src/contracts/discussions.ts +91 -0
- package/src/contracts/geo-document.ts +60 -0
- package/src/contracts/geo-snapshot.ts +86 -0
- package/src/contracts/geo.ts +14 -0
- package/src/contracts/index.ts +2 -0
- package/src/contracts/outgoing-mail.ts +1 -1
- package/src/contracts/resource-types.ts +21 -0
- package/src/geo/AddressAutocomplete.tsx +213 -0
- package/src/geo/messages.ts +29 -0
- package/src/geo/navigation.ts +25 -0
- package/src/geo/ui.ts +2 -0
- package/src/services/accounts/app.ts +28 -6
- package/src/services/accounts/entities.ts +11 -4
- package/src/services/accounts/groups.ts +10 -4
- package/src/services/accounts/local-groups.ts +87 -38
- package/src/services/accounts/machine-group-management.ts +94 -0
- package/src/services/geo/cache.ts +2 -1
- package/src/services/geo/geoapify.ts +3 -0
- package/src/services/identity/invocation-token.ts +110 -0
- package/src/services/pdf/geo-snapshot.ts +107 -0
- package/src/services/pdf/index.ts +2 -0
- package/src/ssr/AdminArea.tsx +2 -2
- package/src/ssr/AdminLayout.tsx +1 -1
- package/src/ssr/AdminSidebar.tsx +1 -1
- package/src/styles/global.css +2 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sql } from "bun";
|
|
1
|
+
import { sql, type TransactionSQL } 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";
|
|
@@ -23,8 +23,8 @@ const toBaseGroup = (row: LocalGroupRow): BaseGroup => ({
|
|
|
23
23
|
personalOwner: null,
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
const getLocalGroupById = async (id: string): Promise<LocalGroupRow | null> => {
|
|
27
|
-
const [row] = await
|
|
26
|
+
const getLocalGroupById = async (id: string, db: typeof sql | TransactionSQL = sql): Promise<LocalGroupRow | null> => {
|
|
27
|
+
const [row] = await db<DbRow[]>`
|
|
28
28
|
SELECT id, provider, name, description, gid_number
|
|
29
29
|
FROM auth.groups
|
|
30
30
|
WHERE id = ${id}::uuid AND provider = 'local'
|
|
@@ -39,20 +39,23 @@ const getLocalGroupById = async (id: string): Promise<LocalGroupRow | null> => {
|
|
|
39
39
|
};
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
const getUserProvider = async (userId: string): Promise<UserProvider | null> => {
|
|
43
|
-
const [row] = await
|
|
42
|
+
const getUserProvider = async (userId: string, db: typeof sql | TransactionSQL = sql): Promise<UserProvider | null> => {
|
|
43
|
+
const [row] = await db<DbRow[]>`SELECT provider FROM auth.users WHERE id = ${userId}`;
|
|
44
44
|
return (row?.provider as UserProvider | undefined) ?? null;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
-
const ensureLocalGroupTreeMember = async (groupId: string): Promise<boolean> => {
|
|
48
|
-
const [row] = await
|
|
47
|
+
const ensureLocalGroupTreeMember = async (groupId: string, db: typeof sql | TransactionSQL = sql): Promise<boolean> => {
|
|
48
|
+
const [row] = await db<DbRow[]>`SELECT 1 FROM auth.groups WHERE id = ${groupId} AND provider = 'local'`;
|
|
49
49
|
return Boolean(row);
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
-
const wouldCreateLocalGroupCycle = async (
|
|
52
|
+
const wouldCreateLocalGroupCycle = async (
|
|
53
|
+
params: { parentGroupId: string; childGroupId: string },
|
|
54
|
+
db: typeof sql | TransactionSQL = sql,
|
|
55
|
+
): Promise<boolean> => {
|
|
53
56
|
if (params.parentGroupId === params.childGroupId) return true;
|
|
54
57
|
|
|
55
|
-
const [row] = await
|
|
58
|
+
const [row] = await db<DbRow[]>`
|
|
56
59
|
WITH RECURSIVE descendants AS (
|
|
57
60
|
SELECT gg.child_group_id
|
|
58
61
|
FROM auth.group_groups_v2 gg
|
|
@@ -272,7 +275,11 @@ export const getMembers = async (params: { id: string; type?: GroupMember["type"
|
|
|
272
275
|
return members;
|
|
273
276
|
};
|
|
274
277
|
|
|
275
|
-
export const getManagers = async (params: {
|
|
278
|
+
export const getManagers = async (params: {
|
|
279
|
+
id: string;
|
|
280
|
+
type?: "user" | "group" | "service_account";
|
|
281
|
+
recursive?: boolean;
|
|
282
|
+
}): Promise<GroupMember[]> => {
|
|
276
283
|
const group = await getLocalGroupById(params.id);
|
|
277
284
|
if (!group) return [];
|
|
278
285
|
|
|
@@ -342,6 +349,16 @@ export const getManagers = async (params: { id: string; type?: "user" | "group";
|
|
|
342
349
|
}
|
|
343
350
|
}
|
|
344
351
|
|
|
352
|
+
if (!params.type || params.type === "service_account") {
|
|
353
|
+
const rows = await sql<{ id: string; name: string; kind: "standalone" | "agent" }[]>`
|
|
354
|
+
SELECT sa.id, sa.name, sa.kind FROM auth.group_manager_service_accounts gm
|
|
355
|
+
JOIN auth.service_accounts sa ON sa.id = gm.service_account_id
|
|
356
|
+
WHERE gm.group_id = ${group.id}::uuid AND sa.kind IN ('standalone', 'agent')
|
|
357
|
+
ORDER BY sa.name, sa.id`;
|
|
358
|
+
managers.push(
|
|
359
|
+
...rows.map((row) => ({ type: "service_account" as const, id: row.id, displayName: row.name, serviceAccountKind: row.kind })),
|
|
360
|
+
);
|
|
361
|
+
}
|
|
345
362
|
return managers;
|
|
346
363
|
};
|
|
347
364
|
|
|
@@ -416,25 +433,28 @@ export const getManagedGroups = async (params: { id: string; recursive?: boolean
|
|
|
416
433
|
return rows.map((row) => row.group_id as string);
|
|
417
434
|
};
|
|
418
435
|
|
|
419
|
-
export const addMember = async (
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
436
|
+
export const addMember = async (
|
|
437
|
+
params: {
|
|
438
|
+
id: string;
|
|
439
|
+
user?: string;
|
|
440
|
+
group?: string;
|
|
441
|
+
serviceAccount?: string;
|
|
442
|
+
},
|
|
443
|
+
db: typeof sql | TransactionSQL = sql,
|
|
444
|
+
): Promise<MutationResult<void>> => {
|
|
445
|
+
const group = await getLocalGroupById(params.id, db);
|
|
426
446
|
if (!group) return { ok: false, error: "Group not found", status: 404 };
|
|
427
447
|
const [personal] = await sql`SELECT 1 FROM auth.personal_groups
|
|
428
448
|
WHERE group_id = ${group.id}::uuid OR group_id = ${params.group ?? null}::uuid`;
|
|
429
449
|
if (personal) return { ok: false, error: "Personal Linux groups cannot be used in group relationships", status: 409 };
|
|
430
450
|
|
|
431
451
|
if (params.serviceAccount) {
|
|
432
|
-
const [account] = await
|
|
452
|
+
const [account] = await db<{ id: string }[]>`
|
|
433
453
|
SELECT id FROM auth.service_accounts
|
|
434
454
|
WHERE id = ${params.serviceAccount}::uuid AND status = 'active' AND kind IN ('standalone', 'agent')
|
|
435
455
|
`;
|
|
436
456
|
if (!account) return { ok: false, error: "An active standalone or agent account is required", status: 400 };
|
|
437
|
-
const inserted = await
|
|
457
|
+
const inserted = await db`
|
|
438
458
|
INSERT INTO auth.service_account_groups (service_account_id, group_id)
|
|
439
459
|
VALUES (${account.id}::uuid, ${group.id}::uuid) ON CONFLICT DO NOTHING
|
|
440
460
|
`;
|
|
@@ -443,9 +463,9 @@ export const addMember = async (params: {
|
|
|
443
463
|
}
|
|
444
464
|
|
|
445
465
|
if (params.user) {
|
|
446
|
-
const provider = await getUserProvider(params.user);
|
|
466
|
+
const provider = await getUserProvider(params.user, db);
|
|
447
467
|
if (!provider) return { ok: false, error: "User not found", status: 404 };
|
|
448
|
-
const [existing] = await
|
|
468
|
+
const [existing] = await db<DbRow[]>`
|
|
449
469
|
SELECT 1
|
|
450
470
|
FROM auth.user_groups_v2
|
|
451
471
|
WHERE user_id = ${params.user}::uuid
|
|
@@ -453,14 +473,14 @@ export const addMember = async (params: {
|
|
|
453
473
|
LIMIT 1
|
|
454
474
|
`;
|
|
455
475
|
if (existing) return { ok: false, error: "User is already a direct member of this group", status: 409 };
|
|
456
|
-
await
|
|
476
|
+
await db`INSERT INTO auth.user_groups_v2 (user_id, group_id) VALUES (${params.user}, ${group.id}) ON CONFLICT DO NOTHING`;
|
|
457
477
|
return { ok: true, data: undefined };
|
|
458
478
|
}
|
|
459
479
|
|
|
460
480
|
if (params.group) {
|
|
461
|
-
const isLocal = await ensureLocalGroupTreeMember(params.group);
|
|
481
|
+
const isLocal = await ensureLocalGroupTreeMember(params.group, db);
|
|
462
482
|
if (!isLocal) return { ok: false, error: "Only local groups can be nested into local groups", status: 400 };
|
|
463
|
-
const [existing] = await
|
|
483
|
+
const [existing] = await db<DbRow[]>`
|
|
464
484
|
SELECT 1
|
|
465
485
|
FROM auth.group_groups_v2
|
|
466
486
|
WHERE parent_group_id = ${group.id}::uuid
|
|
@@ -468,51 +488,69 @@ export const addMember = async (params: {
|
|
|
468
488
|
LIMIT 1
|
|
469
489
|
`;
|
|
470
490
|
if (existing) return { ok: false, error: "Group is already a direct member of this group", status: 409 };
|
|
471
|
-
if (await wouldCreateLocalGroupCycle({ parentGroupId: group.id, childGroupId: params.group })) {
|
|
491
|
+
if (await wouldCreateLocalGroupCycle({ parentGroupId: group.id, childGroupId: params.group }, db)) {
|
|
472
492
|
return { ok: false, error: "Local group nesting cannot create cycles", status: 400 };
|
|
473
493
|
}
|
|
474
|
-
await
|
|
494
|
+
await db`INSERT INTO auth.group_groups_v2 (parent_group_id, child_group_id) VALUES (${group.id}, ${params.group}) ON CONFLICT DO NOTHING`;
|
|
475
495
|
return { ok: true, data: undefined };
|
|
476
496
|
}
|
|
477
497
|
|
|
478
498
|
return { ok: false, error: "Missing member", status: 400 };
|
|
479
499
|
};
|
|
480
500
|
|
|
481
|
-
export const removeMember = async (
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
501
|
+
export const removeMember = async (
|
|
502
|
+
params: {
|
|
503
|
+
id: string;
|
|
504
|
+
user?: string;
|
|
505
|
+
group?: string;
|
|
506
|
+
serviceAccount?: string;
|
|
507
|
+
},
|
|
508
|
+
db: typeof sql | TransactionSQL = sql,
|
|
509
|
+
): Promise<MutationResult<void>> => {
|
|
510
|
+
const group = await getLocalGroupById(params.id, db);
|
|
488
511
|
if (!group) return { ok: false, error: "Group not found", status: 404 };
|
|
489
512
|
|
|
490
513
|
if (params.serviceAccount) {
|
|
491
|
-
await
|
|
514
|
+
await db`DELETE FROM auth.service_account_groups
|
|
492
515
|
WHERE service_account_id = ${params.serviceAccount}::uuid AND group_id = ${group.id}::uuid`;
|
|
493
516
|
return { ok: true, data: undefined };
|
|
494
517
|
}
|
|
495
518
|
|
|
496
519
|
if (params.user) {
|
|
497
|
-
await
|
|
520
|
+
await db`DELETE FROM auth.user_groups_v2 WHERE user_id = ${params.user}::uuid AND group_id = ${group.id}::uuid`;
|
|
498
521
|
return { ok: true, data: undefined };
|
|
499
522
|
}
|
|
500
523
|
|
|
501
524
|
if (params.group) {
|
|
502
|
-
await
|
|
525
|
+
await db`DELETE FROM auth.group_groups_v2 WHERE parent_group_id = ${group.id}::uuid AND child_group_id = ${params.group}::uuid`;
|
|
503
526
|
return { ok: true, data: undefined };
|
|
504
527
|
}
|
|
505
528
|
|
|
506
529
|
return { ok: false, error: "Missing member", status: 400 };
|
|
507
530
|
};
|
|
508
531
|
|
|
509
|
-
export const addManager = async (params: {
|
|
532
|
+
export const addManager = async (params: {
|
|
533
|
+
id: string;
|
|
534
|
+
user?: string;
|
|
535
|
+
group?: string;
|
|
536
|
+
serviceAccount?: string;
|
|
537
|
+
}): Promise<MutationResult<void>> => {
|
|
510
538
|
const group = await getLocalGroupById(params.id);
|
|
511
539
|
if (!group) return { ok: false, error: "Group not found", status: 404 };
|
|
512
540
|
const [personal] = await sql`SELECT 1 FROM auth.personal_groups
|
|
513
541
|
WHERE group_id = ${group.id}::uuid OR group_id = ${params.group ?? null}::uuid`;
|
|
514
542
|
if (personal) return { ok: false, error: "Personal Linux groups cannot be used in group relationships", status: 409 };
|
|
515
543
|
|
|
544
|
+
if (params.serviceAccount) {
|
|
545
|
+
const [account] = await sql`SELECT id FROM auth.service_accounts
|
|
546
|
+
WHERE id = ${params.serviceAccount}::uuid AND status = 'active' AND kind IN ('standalone', 'agent')`;
|
|
547
|
+
if (!account) return { ok: false, error: "An active standalone or agent account is required", status: 400 };
|
|
548
|
+
const inserted = await sql`INSERT INTO auth.group_manager_service_accounts(group_id, service_account_id)
|
|
549
|
+
VALUES (${group.id}::uuid, ${params.serviceAccount}::uuid) ON CONFLICT DO NOTHING`;
|
|
550
|
+
if (inserted.count === 0) return { ok: false, error: "Service account already manages this group", status: 409 };
|
|
551
|
+
return { ok: true, data: undefined };
|
|
552
|
+
}
|
|
553
|
+
|
|
516
554
|
if (params.user) {
|
|
517
555
|
const provider = await getUserProvider(params.user);
|
|
518
556
|
if (!provider) return { ok: false, error: "User not found", status: 404 };
|
|
@@ -546,10 +584,21 @@ export const addManager = async (params: { id: string; user?: string; group?: st
|
|
|
546
584
|
return { ok: false, error: "Missing manager", status: 400 };
|
|
547
585
|
};
|
|
548
586
|
|
|
549
|
-
export const removeManager = async (params: {
|
|
587
|
+
export const removeManager = async (params: {
|
|
588
|
+
id: string;
|
|
589
|
+
user?: string;
|
|
590
|
+
group?: string;
|
|
591
|
+
serviceAccount?: string;
|
|
592
|
+
}): Promise<MutationResult<void>> => {
|
|
550
593
|
const group = await getLocalGroupById(params.id);
|
|
551
594
|
if (!group) return { ok: false, error: "Group not found", status: 404 };
|
|
552
595
|
|
|
596
|
+
if (params.serviceAccount) {
|
|
597
|
+
await sql`DELETE FROM auth.group_manager_service_accounts
|
|
598
|
+
WHERE group_id = ${group.id}::uuid AND service_account_id = ${params.serviceAccount}::uuid`;
|
|
599
|
+
return { ok: true, data: undefined };
|
|
600
|
+
}
|
|
601
|
+
|
|
553
602
|
if (params.user) {
|
|
554
603
|
await sql`DELETE FROM auth.group_manager_users_v2 WHERE group_id = ${group.id}::uuid AND user_id = ${params.user}::uuid`;
|
|
555
604
|
return { ok: true, data: undefined };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import type { ServiceAccountRequestActor } from "../../server/middleware/auth";
|
|
3
|
+
import { err, fail, ok, type Result } from "../../server/services";
|
|
4
|
+
import { audit } from "../audit";
|
|
5
|
+
import * as localGroups from "./local-groups";
|
|
6
|
+
|
|
7
|
+
/** Runs the existing local membership operation while its direct management grant is locked. */
|
|
8
|
+
export async function mutateMachineGroupMember(params: {
|
|
9
|
+
actor: ServiceAccountRequestActor;
|
|
10
|
+
id: string;
|
|
11
|
+
userId?: string;
|
|
12
|
+
groupId?: string;
|
|
13
|
+
serviceAccountId?: string;
|
|
14
|
+
operation: "add" | "remove";
|
|
15
|
+
requestId?: string;
|
|
16
|
+
}): Promise<Result<void>> {
|
|
17
|
+
const { actor } = params;
|
|
18
|
+
const action = `accounts.group.member.${params.operation}`;
|
|
19
|
+
const target = { type: "group", id: params.id };
|
|
20
|
+
const metadata = {
|
|
21
|
+
actorServiceAccountId: actor.serviceAccount.id,
|
|
22
|
+
credentialId: actor.credentialId ?? null,
|
|
23
|
+
oauthClientId: actor.oauthClientId ?? null,
|
|
24
|
+
userId: params.userId ?? null,
|
|
25
|
+
groupId: params.groupId ?? null,
|
|
26
|
+
serviceAccountId: params.serviceAccountId ?? null,
|
|
27
|
+
};
|
|
28
|
+
const unexpired = (expiration: string | Date | null | undefined) =>
|
|
29
|
+
expiration == null || (typeof expiration === "string" ? Date.parse(expiration) : expiration.getTime()) > Date.now();
|
|
30
|
+
const denied = () => fail(err.forbidden("A current management grant for this group and write scope are required"));
|
|
31
|
+
if (
|
|
32
|
+
actor.delegatedUser ||
|
|
33
|
+
!["standalone", "agent"].includes(actor.serviceAccount.kind) ||
|
|
34
|
+
!actor.scopes.some((scope) => scope === "write" || scope === "admin") ||
|
|
35
|
+
!unexpired(actor.credentialExpiresAt) ||
|
|
36
|
+
(!actor.credentialId && (!actor.oauthClientId || !actor.credentialExpiresAt))
|
|
37
|
+
)
|
|
38
|
+
return audit.recordResult({ action, target, metadata, requestId: params.requestId, result: denied() });
|
|
39
|
+
|
|
40
|
+
return sql.begin(async (db) => {
|
|
41
|
+
// Account -> credential -> group -> grant matches the group deletion cascade.
|
|
42
|
+
const [account] = await db`SELECT id FROM auth.service_accounts
|
|
43
|
+
WHERE id = ${actor.serviceAccount.id}::uuid AND status = 'active' AND kind IN ('standalone','agent') FOR SHARE`;
|
|
44
|
+
let allowed = Boolean(account);
|
|
45
|
+
let storedExpiration: Date | null = null;
|
|
46
|
+
if (allowed && actor.credentialId) {
|
|
47
|
+
const [credential] = await db<{ expires_at: Date | null }[]>`SELECT expires_at FROM auth.service_account_credentials
|
|
48
|
+
WHERE id = ${actor.credentialId}::uuid AND service_account_id = ${actor.serviceAccount.id}::uuid
|
|
49
|
+
AND status = 'active' AND (expires_at IS NULL OR expires_at > clock_timestamp())
|
|
50
|
+
AND scopes && ARRAY['write','admin']::text[] FOR SHARE`;
|
|
51
|
+
allowed = Boolean(credential);
|
|
52
|
+
storedExpiration = credential?.expires_at ?? null;
|
|
53
|
+
} else if (allowed) {
|
|
54
|
+
const [client] = await db`SELECT id FROM oauth.clients WHERE client_id=${actor.oauthClientId ?? null} FOR SHARE`;
|
|
55
|
+
allowed = Boolean(client);
|
|
56
|
+
}
|
|
57
|
+
const [group] = allowed ? await db`SELECT id FROM auth.groups WHERE id=${params.id}::uuid AND provider='local' FOR SHARE` : [];
|
|
58
|
+
const [grant] = group
|
|
59
|
+
? await db`SELECT group_id FROM auth.group_manager_service_accounts
|
|
60
|
+
WHERE group_id=${params.id}::uuid AND service_account_id=${actor.serviceAccount.id}::uuid FOR SHARE`
|
|
61
|
+
: [];
|
|
62
|
+
let result: Result<void> = denied();
|
|
63
|
+
// The authorization locks define admission; time must be current after any lock wait.
|
|
64
|
+
if (grant && unexpired(actor.credentialExpiresAt) && unexpired(storedExpiration)) {
|
|
65
|
+
if ([params.userId, params.groupId, params.serviceAccountId].filter(Boolean).length !== 1) {
|
|
66
|
+
result = fail(err.badInput("Choose exactly one member"));
|
|
67
|
+
} else {
|
|
68
|
+
const changed = await localGroups[params.operation === "add" ? "addMember" : "removeMember"](
|
|
69
|
+
{
|
|
70
|
+
id: params.id,
|
|
71
|
+
user: params.userId,
|
|
72
|
+
group: params.groupId,
|
|
73
|
+
serviceAccount: params.serviceAccountId,
|
|
74
|
+
},
|
|
75
|
+
db,
|
|
76
|
+
);
|
|
77
|
+
result = changed.ok
|
|
78
|
+
? ok()
|
|
79
|
+
: fail({ code: changed.status === 403 ? "FORBIDDEN" : "GROUP_MUTATION_FAILED", message: changed.error, status: changed.status });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
await audit.record(
|
|
83
|
+
{
|
|
84
|
+
action,
|
|
85
|
+
target,
|
|
86
|
+
metadata,
|
|
87
|
+
requestId: params.requestId,
|
|
88
|
+
outcome: result.ok ? "allowed" : result.error.status === 403 ? "denied" : "failed",
|
|
89
|
+
},
|
|
90
|
+
db,
|
|
91
|
+
);
|
|
92
|
+
return result;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -4,13 +4,14 @@ import type { GeoFunction } from "../../contracts/geo";
|
|
|
4
4
|
import { requestCacheRedis } from "../request-cache-redis";
|
|
5
5
|
import type { GeoRedis } from "./bucket";
|
|
6
6
|
|
|
7
|
-
export const GEO_ADAPTER_VERSION = "geoapify-
|
|
7
|
+
export const GEO_ADAPTER_VERSION = "geoapify-v3";
|
|
8
8
|
export const GEO_MAX_JSON_BYTES = 256 * 1024;
|
|
9
9
|
// Raw provider geometry is compacted before entering the public result/cache budget.
|
|
10
10
|
export const GEO_MAX_ROUTE_RESPONSE_BYTES = 26 * GEO_MAX_JSON_BYTES;
|
|
11
11
|
const hash = (value: string) => createHash("sha256").update(value).digest("hex");
|
|
12
12
|
export const normalizeGeoInput = (input: unknown, fn: GeoFunction, path = ""): unknown => {
|
|
13
13
|
const field = path.split(".").at(-1);
|
|
14
|
+
if (typeof input === "string" && fn === "places" && field === "name") return input.trim();
|
|
14
15
|
if (typeof input === "string" && fn === "route") return field === "polyline6" ? hash(input) : input;
|
|
15
16
|
if (typeof input === "string") return field === "polyline6" ? hash(input) : input.toLowerCase().trim().replace(/\s+/g, " ");
|
|
16
17
|
if (typeof input === "number") {
|
|
@@ -86,6 +86,7 @@ const address = (p: RawProperties, provenance: GeoProvenance): GeoAddress => ({
|
|
|
86
86
|
point: { lat: p.lat, lon: p.lon },
|
|
87
87
|
formatted: p.formatted ?? p.address_line1 ?? "",
|
|
88
88
|
confidence: p.rank?.confidence ?? null,
|
|
89
|
+
buildingConfidence: p.rank?.confidence_building_level ?? null,
|
|
89
90
|
provenance,
|
|
90
91
|
});
|
|
91
92
|
const areaFilter = (area: NonNullable<GeoAutocompleteInput["area"]>) =>
|
|
@@ -247,6 +248,7 @@ export class GeoapifyAdapter {
|
|
|
247
248
|
if (input.along && line)
|
|
248
249
|
body = {
|
|
249
250
|
categories,
|
|
251
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
250
252
|
limit: input.limit ?? 20,
|
|
251
253
|
filter: {
|
|
252
254
|
type: "polyline",
|
|
@@ -256,6 +258,7 @@ export class GeoapifyAdapter {
|
|
|
256
258
|
};
|
|
257
259
|
else {
|
|
258
260
|
params.set("categories", categories.join(","));
|
|
261
|
+
if (input.name !== undefined) params.set("name", input.name);
|
|
259
262
|
if (input.near) params.set("bias", `proximity:${input.near.lon},${input.near.lat}`);
|
|
260
263
|
if (input.area) params.set("filter", areaFilter(input.area));
|
|
261
264
|
}
|
|
@@ -485,3 +485,113 @@ export const verifyAppAccessProbe = async (
|
|
|
485
485
|
return null;
|
|
486
486
|
}
|
|
487
487
|
};
|
|
488
|
+
|
|
489
|
+
// A separate payload prevents app authority from ever entering the person/mandate resolver.
|
|
490
|
+
const AppQueryClaimsSchema = z
|
|
491
|
+
.object({
|
|
492
|
+
iss: z.string().url(),
|
|
493
|
+
aud: z.string().regex(/^app:[a-z][a-z0-9-]{0,79}$/),
|
|
494
|
+
token_use: z.literal("app_query"),
|
|
495
|
+
sub: AppIdSchema,
|
|
496
|
+
credential_id: z.uuid(),
|
|
497
|
+
service_account_id: z.uuid(),
|
|
498
|
+
op: OperationSchema.refine((value) => value.startsWith("capability.query:")),
|
|
499
|
+
schema_hash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
500
|
+
input_hash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
501
|
+
request_id: BoundedIdentifierSchema,
|
|
502
|
+
jti: z.uuid(),
|
|
503
|
+
iat: z.number().int(),
|
|
504
|
+
nbf: z.number().int(),
|
|
505
|
+
exp: z.number().int(),
|
|
506
|
+
})
|
|
507
|
+
.strict()
|
|
508
|
+
.refine((claims) => claims.nbf === claims.iat && claims.exp - claims.iat === CLOUD_INVOCATION_TOKEN_TTL_SECONDS);
|
|
509
|
+
|
|
510
|
+
export const signAppQueryInvocation = async (input: {
|
|
511
|
+
workload: import("./workload-auth").AuthenticatedWorkload;
|
|
512
|
+
targetAppId: string;
|
|
513
|
+
capabilityId: string;
|
|
514
|
+
schemaHash: string;
|
|
515
|
+
input: unknown;
|
|
516
|
+
requestId: string;
|
|
517
|
+
signer: PreparedIdentitySigner & { issuer: string };
|
|
518
|
+
}) => {
|
|
519
|
+
const now = Math.floor(Date.now() / 1000);
|
|
520
|
+
if (input.signer.signUntil.getTime() < (now + CLOUD_INVOCATION_TOKEN_TTL_SECONDS) * 1000)
|
|
521
|
+
throw new Error("Signing key expires before the app query");
|
|
522
|
+
const claims = AppQueryClaimsSchema.parse({
|
|
523
|
+
iss: input.signer.issuer,
|
|
524
|
+
aud: `app:${input.targetAppId}`,
|
|
525
|
+
token_use: "app_query",
|
|
526
|
+
sub: input.workload.appId,
|
|
527
|
+
credential_id: input.workload.credentialId,
|
|
528
|
+
service_account_id: input.workload.serviceAccountId,
|
|
529
|
+
op: `capability.query:${input.capabilityId}`,
|
|
530
|
+
schema_hash: input.schemaHash,
|
|
531
|
+
input_hash: appEventPayloadHash(input.input),
|
|
532
|
+
request_id: input.requestId,
|
|
533
|
+
jti: crypto.randomUUID(),
|
|
534
|
+
iat: now,
|
|
535
|
+
nbf: now,
|
|
536
|
+
exp: now + CLOUD_INVOCATION_TOKEN_TTL_SECONDS,
|
|
537
|
+
});
|
|
538
|
+
const token = await new SignJWT(claims)
|
|
539
|
+
.setProtectedHeader({ alg: CLOUD_IDENTITY_ALGORITHM, typ: CLOUD_INVOCATION_TOKEN_TYPE, kid: input.signer.kid })
|
|
540
|
+
.sign(input.signer.key);
|
|
541
|
+
if (Buffer.byteLength(token) > IDENTITY_MAX_COMPACT_TOKEN_BYTES) throw new Error("App query token exceeds its byte budget");
|
|
542
|
+
return { token, claims };
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
export const verifyAppQueryInvocation = async (
|
|
546
|
+
token: string,
|
|
547
|
+
expected: { appId: string; capabilityId: string; schemaHash: string; input: unknown },
|
|
548
|
+
options: { issuer?: string; key?: JWTVerifyGetKey; now?: Date } = {},
|
|
549
|
+
) => {
|
|
550
|
+
if (Buffer.byteLength(token) > IDENTITY_MAX_COMPACT_TOKEN_BYTES) return null;
|
|
551
|
+
try {
|
|
552
|
+
assertProtectedHeader(decodeProtectedHeader(token));
|
|
553
|
+
} catch {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
// Configuration and key-fetch failures must remain retryable, rather than look like invalid tokens.
|
|
557
|
+
const runtime = options.issuer && options.key ? null : await getIdentityRuntimeConfig();
|
|
558
|
+
try {
|
|
559
|
+
const issuer = options.issuer ?? runtime!.issuer;
|
|
560
|
+
const state = options.key ? null : remoteSet(runtime!.invocationJwksUrl);
|
|
561
|
+
const key = options.key ?? state!.key;
|
|
562
|
+
const verified = await verifyWithKeyRefresh(
|
|
563
|
+
(candidate) =>
|
|
564
|
+
jwtVerify(token, candidate, {
|
|
565
|
+
algorithms: [CLOUD_IDENTITY_ALGORITHM],
|
|
566
|
+
issuer,
|
|
567
|
+
audience: `app:${expected.appId}`,
|
|
568
|
+
typ: CLOUD_INVOCATION_TOKEN_TYPE,
|
|
569
|
+
clockTolerance: INVOCATION_CLOCK_TOLERANCE_SECONDS,
|
|
570
|
+
currentDate: options.now,
|
|
571
|
+
}),
|
|
572
|
+
key,
|
|
573
|
+
state ? { url: runtime!.invocationJwksUrl, state } : null,
|
|
574
|
+
);
|
|
575
|
+
const claims = AppQueryClaimsSchema.parse(verified.payload);
|
|
576
|
+
if (
|
|
577
|
+
claims.op !== `capability.query:${expected.capabilityId}` ||
|
|
578
|
+
claims.schema_hash !== expected.schemaHash ||
|
|
579
|
+
claims.input_hash !== appEventPayloadHash(expected.input)
|
|
580
|
+
)
|
|
581
|
+
return null;
|
|
582
|
+
return claims;
|
|
583
|
+
} catch (error) {
|
|
584
|
+
if (
|
|
585
|
+
error instanceof z.ZodError ||
|
|
586
|
+
error instanceof errors.JWTExpired ||
|
|
587
|
+
error instanceof errors.JWTClaimValidationFailed ||
|
|
588
|
+
error instanceof errors.JWTInvalid ||
|
|
589
|
+
error instanceof errors.JWSInvalid ||
|
|
590
|
+
error instanceof errors.JWSSignatureVerificationFailed ||
|
|
591
|
+
error instanceof errors.JWKSNoMatchingKey ||
|
|
592
|
+
error instanceof errors.JOSENotSupported
|
|
593
|
+
)
|
|
594
|
+
return null;
|
|
595
|
+
throw error;
|
|
596
|
+
}
|
|
597
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GEO_SNAPSHOT_MAX_BYTES,
|
|
3
|
+
GEO_SNAPSHOT_MAX_METADATA_BYTES,
|
|
4
|
+
type GeoMapSnapshotManifest,
|
|
5
|
+
GeoMapSnapshotManifestSchema,
|
|
6
|
+
geoMapSnapshotPart,
|
|
7
|
+
} from "../../contracts/geo-snapshot";
|
|
8
|
+
import { type PreparePdfImageOptions, preparePdfImage } from "./image";
|
|
9
|
+
|
|
10
|
+
export type GeoMapSnapshotAsset = { id: string; name: string; data: Blob; type: "image/png" | "image/jpeg"; width: number; height: number };
|
|
11
|
+
export type GeoMapSnapshotAssetErrorCode = "invalid" | "stale" | "missing" | "too_large" | "unreadable" | "timed_out" | "aborted";
|
|
12
|
+
export class GeoMapSnapshotAssetError extends Error {
|
|
13
|
+
constructor(readonly code: GeoMapSnapshotAssetErrorCode) {
|
|
14
|
+
super(`Map snapshot upload: ${code}`);
|
|
15
|
+
this.name = "GeoMapSnapshotAssetError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Application calls only after authorizing and reading its canonical saved revision/maps.
|
|
21
|
+
* Validates a presentation upload; it cannot attest that the client's pixels depict the saved geography.
|
|
22
|
+
* Enforce a streaming HTTP body limit BEFORE parsing FormData. Never accept resource URLs or renderer credentials.
|
|
23
|
+
*/
|
|
24
|
+
export async function prepareGeoMapSnapshotAssets(
|
|
25
|
+
form: FormData,
|
|
26
|
+
expected: { revision: string; maps: Pick<GeoMapSnapshotManifest["images"][number], "id" | "fingerprint">[]; maxBytes: number },
|
|
27
|
+
options: PreparePdfImageOptions = {},
|
|
28
|
+
): Promise<GeoMapSnapshotAsset[]> {
|
|
29
|
+
const fail = (code: GeoMapSnapshotAssetErrorCode): never => {
|
|
30
|
+
throw new GeoMapSnapshotAssetError(code);
|
|
31
|
+
};
|
|
32
|
+
if (!Number.isSafeInteger(expected.maxBytes) || expected.maxBytes < 0) throw new RangeError("Invalid PDF asset budget");
|
|
33
|
+
const timeout = options.timeoutMs ?? 30_000;
|
|
34
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 240_000) throw new RangeError("Invalid map preflight timeout");
|
|
35
|
+
const deadline = Date.now() + timeout;
|
|
36
|
+
const check = () => {
|
|
37
|
+
if (options.signal?.aborted) fail("aborted");
|
|
38
|
+
if (Date.now() >= deadline) fail("timed_out");
|
|
39
|
+
};
|
|
40
|
+
check();
|
|
41
|
+
const metadata = form.getAll("mapSnapshots");
|
|
42
|
+
if (metadata.length !== 1 || typeof metadata[0] !== "string") return fail("invalid");
|
|
43
|
+
if (new TextEncoder().encode(metadata[0]).byteLength > GEO_SNAPSHOT_MAX_METADATA_BYTES) return fail("too_large");
|
|
44
|
+
let manifest: GeoMapSnapshotManifest;
|
|
45
|
+
try {
|
|
46
|
+
manifest = GeoMapSnapshotManifestSchema.parse(JSON.parse(metadata[0]));
|
|
47
|
+
} catch {
|
|
48
|
+
return fail("invalid");
|
|
49
|
+
}
|
|
50
|
+
if (manifest.revision !== expected.revision) return fail("stale");
|
|
51
|
+
const maps = new Map(expected.maps.map((map) => [map.id, map.fingerprint]));
|
|
52
|
+
if (maps.size !== expected.maps.length) throw new Error("Expected maps must have unique IDs");
|
|
53
|
+
if (manifest.images.length !== maps.size) return fail("missing");
|
|
54
|
+
for (const image of manifest.images) {
|
|
55
|
+
if (!maps.has(image.id)) return fail("invalid");
|
|
56
|
+
if (maps.get(image.id) !== image.fingerprint) return fail("stale");
|
|
57
|
+
}
|
|
58
|
+
const names = new Set(manifest.images.map((image) => geoMapSnapshotPart(image.id)));
|
|
59
|
+
for (const name of form.keys()) if (name.startsWith("map-snapshot-") && !names.has(name)) return fail("invalid");
|
|
60
|
+
const maximum = Math.min(expected.maxBytes, GEO_SNAPSHOT_MAX_BYTES);
|
|
61
|
+
let inputBytes = 0;
|
|
62
|
+
const uploads = manifest.images.map((image) => {
|
|
63
|
+
const parts = form.getAll(geoMapSnapshotPart(image.id));
|
|
64
|
+
if (parts.length !== 1 || !(parts[0] instanceof Blob)) return fail("missing");
|
|
65
|
+
const data = parts[0];
|
|
66
|
+
if (data.type !== "image/png") return fail("invalid");
|
|
67
|
+
inputBytes += data.size;
|
|
68
|
+
if (data.size < 33 || inputBytes > maximum) return fail("too_large");
|
|
69
|
+
return { image, data };
|
|
70
|
+
});
|
|
71
|
+
// Check every PNG IHDR before starting any expensive decoder. Filenames are ignored.
|
|
72
|
+
for (const { image, data } of uploads) {
|
|
73
|
+
check();
|
|
74
|
+
const header = new Uint8Array(await data.slice(0, 33).arrayBuffer());
|
|
75
|
+
const view = new DataView(header.buffer);
|
|
76
|
+
if (
|
|
77
|
+
![137, 80, 78, 71, 13, 10, 26, 10].every((byte, index) => header[index] === byte) ||
|
|
78
|
+
view.getUint32(8) !== 13 ||
|
|
79
|
+
view.getUint32(12) !== 0x49484452 ||
|
|
80
|
+
view.getUint32(16) !== image.width ||
|
|
81
|
+
view.getUint32(20) !== image.height
|
|
82
|
+
)
|
|
83
|
+
return fail("invalid");
|
|
84
|
+
}
|
|
85
|
+
const assets: GeoMapSnapshotAsset[] = [];
|
|
86
|
+
let outputBytes = 0;
|
|
87
|
+
for (const { image, data } of uploads) {
|
|
88
|
+
check();
|
|
89
|
+
const remaining = maximum - outputBytes;
|
|
90
|
+
if (remaining < 1) return fail("too_large");
|
|
91
|
+
const prepared = await preparePdfImage(
|
|
92
|
+
{ data, boxes: [{ width: image.width, height: image.height }], maxBytes: remaining },
|
|
93
|
+
{
|
|
94
|
+
signal: options.signal,
|
|
95
|
+
timeoutMs: Math.max(1, deadline - Date.now()),
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
check();
|
|
99
|
+
if (!prepared.ok) return fail(prepared.code === "unsupported_format" ? "invalid" : prepared.code);
|
|
100
|
+
const variant = prepared.variants[0];
|
|
101
|
+
if (!variant) return fail("too_large");
|
|
102
|
+
if (variant.width !== image.width || variant.height !== image.height) return fail("invalid");
|
|
103
|
+
outputBytes += variant.data.size;
|
|
104
|
+
assets.push({ id: image.id, name: `geo-map-${assets.length}.${variant.type === "image/png" ? "png" : "jpg"}`, ...variant });
|
|
105
|
+
}
|
|
106
|
+
return assets;
|
|
107
|
+
}
|
package/src/ssr/AdminArea.tsx
CHANGED
|
@@ -84,7 +84,7 @@ export const AdminAreaSaveStatusHead = () => (
|
|
|
84
84
|
</div>
|
|
85
85
|
);
|
|
86
86
|
export const AdminAreaSaveStatusFoot = () => (
|
|
87
|
-
<div class="sticky bottom-0 flex justify-end bg-(--k2b-surface) px-
|
|
87
|
+
<div class="sticky bottom-0 flex justify-end bg-(--k2b-surface) px-2 py-3 md:hidden">
|
|
88
88
|
<SettingsSaveStatus />
|
|
89
89
|
</div>
|
|
90
90
|
);
|
|
@@ -124,7 +124,7 @@ export function AdminAreaPane(props: {
|
|
|
124
124
|
minSize={props.minSize}
|
|
125
125
|
defaultSize={props.minSize}
|
|
126
126
|
>
|
|
127
|
-
<div class="app-rows
|
|
127
|
+
<div class="app-rows pb-[var(--ui-space-shell)]">{props.children}</div>
|
|
128
128
|
{props.saveStatus ? <AdminAreaSaveStatusFoot /> : null}
|
|
129
129
|
</AppWorkspace.MainPane>
|
|
130
130
|
);
|
package/src/ssr/AdminLayout.tsx
CHANGED
|
@@ -47,7 +47,7 @@ export default function AdminLayout(props: Props) {
|
|
|
47
47
|
const breadcrumbs: Breadcrumb[] = [{ title: t.start, href: "/" }, { title: overview ? t.admin : title }];
|
|
48
48
|
return (
|
|
49
49
|
<Layout c={c} fullWidth fullPage title={breadcrumbs}>
|
|
50
|
-
<AppWorkspace mobileSurface="flush" class="min-h-0 flex-1"
|
|
50
|
+
<AppWorkspace mobileSurface="flush" class="min-h-0 flex-1">
|
|
51
51
|
<AdminSidebar currentPath={currentPath} apps={runtime.apps} />
|
|
52
52
|
<AppWorkspace.Content>
|
|
53
53
|
{props.ownMain ? (
|
package/src/ssr/AdminSidebar.tsx
CHANGED
|
@@ -52,7 +52,7 @@ export default function AdminSidebar({ currentPath, apps }: { currentPath: strin
|
|
|
52
52
|
})),
|
|
53
53
|
}))}
|
|
54
54
|
/>
|
|
55
|
-
<AppWorkspace.Sidebar
|
|
55
|
+
<AppWorkspace.Sidebar>
|
|
56
56
|
<AppWorkspace.SidebarDesktop>
|
|
57
57
|
<AppWorkspace.SidebarBody scrollPreserveKey="admin-sidebar">
|
|
58
58
|
<AdminNavigation currentPath={currentPath} groups={groups()} />
|
package/src/styles/global.css
CHANGED
|
@@ -869,3 +869,5 @@ solid-island {
|
|
|
869
869
|
}
|
|
870
870
|
.cloud-mobile-profile { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .5rem; border-top: 1px solid var(--ui-border); padding-top: 1rem; }
|
|
871
871
|
.cloud-mobile-profile > :is(a, button) { min-height: 2.75rem; white-space: normal; text-align: start; justify-content: flex-start; }
|
|
872
|
+
|
|
873
|
+
@import "../ai/chat/map-widget.css";
|