@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.
Files changed (71) hide show
  1. package/package.json +5 -2
  2. package/src/_internal/app-queries.ts +73 -0
  3. package/src/_internal/capabilities.ts +137 -9
  4. package/src/_internal/define-app.ts +2 -0
  5. package/src/_internal/registry-validation.ts +17 -4
  6. package/src/_internal/registry.ts +32 -4
  7. package/src/_internal/resource-principals.ts +36 -0
  8. package/src/ai/capabilities.ts +4 -2
  9. package/src/ai/chat/map-messages.ts +270 -0
  10. package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
  11. package/src/ai/chat/map-notebook.tsx +301 -0
  12. package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
  13. package/src/ai/chat/map-widget.css +107 -0
  14. package/src/ai/chat/map-widget.tsx +1279 -0
  15. package/src/ai/chat/message-utils.ts +1 -0
  16. package/src/ai/chat/tool-names.ts +2 -0
  17. package/src/ai/chat/turn-layout.ts +2 -1
  18. package/src/ai/chat/turn-view.tsx +17 -1
  19. package/src/ai/default-tools.ts +3 -0
  20. package/src/ai/executor.ts +13 -1
  21. package/src/ai/map-contracts.ts +209 -0
  22. package/src/ai/map-execution.ts +14 -0
  23. package/src/ai/map-notebook-save.ts +276 -0
  24. package/src/ai/map-notebook.ts +32 -0
  25. package/src/ai/map-opening-hours.ts +40 -0
  26. package/src/ai/map-routes.ts +84 -0
  27. package/src/ai/map-service.ts +288 -0
  28. package/src/ai/map-store.ts +137 -0
  29. package/src/ai/map-tool.ts +73 -0
  30. package/src/ai/migrate.ts +3 -0
  31. package/src/ai/protocol.ts +3 -1
  32. package/src/ai/routes.ts +2 -0
  33. package/src/ai/store.ts +19 -6
  34. package/src/ai/tools.ts +14 -0
  35. package/src/ai/types.ts +3 -0
  36. package/src/api/capabilities.ts +90 -12
  37. package/src/api/mcp.ts +4 -2
  38. package/src/browser/notifications.ts +25 -8
  39. package/src/capabilities/discussion-providers.ts +99 -0
  40. package/src/capabilities/executions.ts +2 -2
  41. package/src/capabilities/migrate.ts +10 -1
  42. package/src/capabilities/resource-readers.ts +58 -0
  43. package/src/capabilities/server-invoke.ts +23 -13
  44. package/src/capabilities/server.ts +17 -6
  45. package/src/capabilities/testing.ts +13 -0
  46. package/src/contracts/capabilities.ts +137 -4
  47. package/src/contracts/discussions.ts +91 -0
  48. package/src/contracts/geo-document.ts +60 -0
  49. package/src/contracts/geo-snapshot.ts +86 -0
  50. package/src/contracts/geo.ts +14 -0
  51. package/src/contracts/index.ts +2 -0
  52. package/src/contracts/outgoing-mail.ts +1 -1
  53. package/src/contracts/resource-types.ts +21 -0
  54. package/src/geo/AddressAutocomplete.tsx +213 -0
  55. package/src/geo/messages.ts +29 -0
  56. package/src/geo/navigation.ts +25 -0
  57. package/src/geo/ui.ts +2 -0
  58. package/src/services/accounts/app.ts +28 -6
  59. package/src/services/accounts/entities.ts +11 -4
  60. package/src/services/accounts/groups.ts +10 -4
  61. package/src/services/accounts/local-groups.ts +87 -38
  62. package/src/services/accounts/machine-group-management.ts +94 -0
  63. package/src/services/geo/cache.ts +2 -1
  64. package/src/services/geo/geoapify.ts +3 -0
  65. package/src/services/identity/invocation-token.ts +110 -0
  66. package/src/services/pdf/geo-snapshot.ts +107 -0
  67. package/src/services/pdf/index.ts +2 -0
  68. package/src/ssr/AdminArea.tsx +2 -2
  69. package/src/ssr/AdminLayout.tsx +1 -1
  70. package/src/ssr/AdminSidebar.tsx +1 -1
  71. 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 sql<DbRow[]>`
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 sql<DbRow[]>`SELECT provider FROM auth.users WHERE id = ${userId}`;
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 sql<DbRow[]>`SELECT 1 FROM auth.groups WHERE id = ${groupId} AND provider = 'local'`;
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 (params: { parentGroupId: string; childGroupId: string }): Promise<boolean> => {
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 sql<DbRow[]>`
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: { id: string; type?: "user" | "group"; recursive?: boolean }): Promise<GroupMember[]> => {
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 (params: {
420
- id: string;
421
- user?: string;
422
- group?: string;
423
- serviceAccount?: string;
424
- }): Promise<MutationResult<void>> => {
425
- const group = await getLocalGroupById(params.id);
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 sql<{ id: string }[]>`
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 sql`
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 sql<DbRow[]>`
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 sql`INSERT INTO auth.user_groups_v2 (user_id, group_id) VALUES (${params.user}, ${group.id}) ON CONFLICT DO NOTHING`;
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 sql<DbRow[]>`
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 sql`INSERT INTO auth.group_groups_v2 (parent_group_id, child_group_id) VALUES (${group.id}, ${params.group}) ON CONFLICT DO NOTHING`;
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 (params: {
482
- id: string;
483
- user?: string;
484
- group?: string;
485
- serviceAccount?: string;
486
- }): Promise<MutationResult<void>> => {
487
- const group = await getLocalGroupById(params.id);
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 sql`DELETE FROM auth.service_account_groups
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 sql`DELETE FROM auth.user_groups_v2 WHERE user_id = ${params.user}::uuid AND group_id = ${group.id}::uuid`;
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 sql`DELETE FROM auth.group_groups_v2 WHERE parent_group_id = ${group.id}::uuid AND child_group_id = ${params.group}::uuid`;
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: { id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
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: { id: string; user?: string; group?: string }): Promise<MutationResult<void>> => {
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-v2";
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
+ }
@@ -1,3 +1,5 @@
1
+ export type { GeoMapSnapshotAsset, GeoMapSnapshotAssetErrorCode } from "./geo-snapshot";
2
+ export { GeoMapSnapshotAssetError, prepareGeoMapSnapshotAssets } from "./geo-snapshot";
1
3
  export type {
2
4
  AttachPdfFilesInput,
3
5
  GotenbergConfig,
@@ -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-4 py-3 md:hidden">
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 px-[var(--ui-space-shell)] pb-[var(--ui-space-shell)]">{props.children}</div>
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
  );
@@ -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" resizable={false}>
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 ? (
@@ -52,7 +52,7 @@ export default function AdminSidebar({ currentPath, apps }: { currentPath: strin
52
52
  })),
53
53
  }))}
54
54
  />
55
- <AppWorkspace.Sidebar resizable={false}>
55
+ <AppWorkspace.Sidebar>
56
56
  <AppWorkspace.SidebarDesktop>
57
57
  <AppWorkspace.SidebarBody scrollPreserveKey="admin-sidebar">
58
58
  <AdminNavigation currentPath={currentPath} groups={groups()} />
@@ -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";