@k2b/cloud 0.44.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 (137) hide show
  1. package/package.json +9 -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/access/GroupCoverage.tsx +20 -5
  9. package/src/access/PermissionEditor.tsx +11 -2
  10. package/src/access/PrincipalAvatar.tsx +30 -21
  11. package/src/access/messages.ts +4 -4
  12. package/src/account/EntitySearch.tsx +12 -0
  13. package/src/ai/capabilities.ts +4 -2
  14. package/src/ai/chat/map-messages.ts +270 -0
  15. package/src/ai/chat/map-notebook.browser-harness.tsx +28 -0
  16. package/src/ai/chat/map-notebook.tsx +301 -0
  17. package/src/ai/chat/map-widget.browser-harness.tsx +71 -0
  18. package/src/ai/chat/map-widget.css +107 -0
  19. package/src/ai/chat/map-widget.tsx +1279 -0
  20. package/src/ai/chat/message-utils.ts +1 -0
  21. package/src/ai/chat/tool-names.ts +2 -0
  22. package/src/ai/chat/turn-layout.ts +2 -1
  23. package/src/ai/chat/turn-view.tsx +17 -1
  24. package/src/ai/default-tools.ts +3 -0
  25. package/src/ai/dictation-runtime.ts +6 -2
  26. package/src/ai/executor.ts +22 -4
  27. package/src/ai/index.ts +1 -0
  28. package/src/ai/map-contracts.ts +209 -0
  29. package/src/ai/map-execution.ts +14 -0
  30. package/src/ai/map-notebook-save.ts +276 -0
  31. package/src/ai/map-notebook.ts +32 -0
  32. package/src/ai/map-opening-hours.ts +40 -0
  33. package/src/ai/map-routes.ts +84 -0
  34. package/src/ai/map-service.ts +288 -0
  35. package/src/ai/map-store.ts +137 -0
  36. package/src/ai/map-tool.ts +73 -0
  37. package/src/ai/migrate.ts +3 -0
  38. package/src/ai/project-tool.ts +4 -4
  39. package/src/ai/projects-authorization.ts +53 -0
  40. package/src/ai/projects-routes.ts +56 -46
  41. package/src/ai/projects.ts +100 -79
  42. package/src/ai/protocol.ts +3 -1
  43. package/src/ai/recent-resources.ts +3 -3
  44. package/src/ai/routes.ts +32 -30
  45. package/src/ai/skill-tool.ts +4 -4
  46. package/src/ai/skills-routes.ts +27 -26
  47. package/src/ai/skills.ts +120 -81
  48. package/src/ai/store.ts +19 -6
  49. package/src/ai/tools.ts +14 -0
  50. package/src/ai/types.ts +3 -0
  51. package/src/api/accounts-entities.ts +6 -3
  52. package/src/api/admin-ai-skills.ts +4 -1
  53. package/src/api/auth.ts +9 -2
  54. package/src/api/capabilities.ts +90 -12
  55. package/src/api/mcp.ts +4 -2
  56. package/src/browser/notifications.ts +25 -8
  57. package/src/browser/session.ts +39 -0
  58. package/src/browser/shared-text-history-validation.ts +151 -0
  59. package/src/browser/shared-text-history.ts +464 -0
  60. package/src/browser/shared-text-offline-authorized.ts +29 -0
  61. package/src/browser/shared-text-offline.ts +351 -0
  62. package/src/browser/shared-text-provider.ts +714 -0
  63. package/src/browser/shared-text-table-contract.ts +150 -0
  64. package/src/browser/shared-text.ts +19 -0
  65. package/src/capabilities/discussion-providers.ts +99 -0
  66. package/src/capabilities/executions.ts +2 -2
  67. package/src/capabilities/migrate.ts +10 -1
  68. package/src/capabilities/resource-readers.ts +58 -0
  69. package/src/capabilities/server-invoke.ts +23 -13
  70. package/src/capabilities/server.ts +17 -6
  71. package/src/capabilities/testing.ts +13 -0
  72. package/src/contracts/capabilities.ts +137 -4
  73. package/src/contracts/discussions.ts +91 -0
  74. package/src/contracts/geo-document.ts +60 -0
  75. package/src/contracts/geo-snapshot.ts +86 -0
  76. package/src/contracts/geo.ts +14 -0
  77. package/src/contracts/index.ts +2 -0
  78. package/src/contracts/outgoing-mail.ts +1 -1
  79. package/src/contracts/resource-types.ts +21 -0
  80. package/src/contracts/shared.ts +2 -1
  81. package/src/geo/AddressAutocomplete.tsx +213 -0
  82. package/src/geo/messages.ts +29 -0
  83. package/src/geo/navigation.ts +25 -0
  84. package/src/geo/ui.ts +2 -0
  85. package/src/server/api/respond.ts +10 -2
  86. package/src/server/index.ts +1 -0
  87. package/src/server/services/access.ts +23 -10
  88. package/src/server/services/index.ts +1 -0
  89. package/src/services/accounts/app.ts +56 -17
  90. package/src/services/accounts/entities.ts +36 -16
  91. package/src/services/accounts/group-sql.ts +23 -0
  92. package/src/services/accounts/groups.ts +26 -17
  93. package/src/services/accounts/identities.ts +8 -1
  94. package/src/services/accounts/identity-reconciliation.ts +3 -1
  95. package/src/services/accounts/local-groups.ts +141 -27
  96. package/src/services/accounts/machine-group-management.ts +94 -0
  97. package/src/services/accounts/users.ts +38 -8
  98. package/src/services/geo/cache.ts +2 -1
  99. package/src/services/geo/geoapify.ts +3 -0
  100. package/src/services/identity/invocation-token.ts +110 -0
  101. package/src/services/mandates/index.ts +2 -2
  102. package/src/services/mandates/policy.ts +1 -0
  103. package/src/services/pdf/geo-snapshot.ts +107 -0
  104. package/src/services/pdf/index.ts +2 -0
  105. package/src/services/session/index.ts +12 -3
  106. package/src/shared/email-html.ts +1 -0
  107. package/src/shared/markdown/extensions/katex.ts +8 -1
  108. package/src/shared/markdown/extensions/tables.ts +14 -1
  109. package/src/shared/markdown/index.ts +22 -11
  110. package/src/shared-text/anchors.ts +29 -0
  111. package/src/shared-text/awareness.ts +87 -0
  112. package/src/shared-text/changes.ts +27 -0
  113. package/src/shared-text/documents.ts +428 -77
  114. package/src/shared-text/events.ts +12 -2
  115. package/src/shared-text/index.ts +16 -1
  116. package/src/shared-text/log.ts +24 -5
  117. package/src/shared-text/snapshots.ts +69 -23
  118. package/src/shared-text/structural-history.ts +377 -0
  119. package/src/shared-text/table-actions.ts +432 -0
  120. package/src/shared-text/types.ts +24 -5
  121. package/src/ssr/AdminArea.tsx +2 -2
  122. package/src/ssr/AdminLayout.tsx +1 -1
  123. package/src/ssr/AdminSidebar.tsx +1 -1
  124. package/src/ssr/AppLaunchpadPanel.tsx +1 -1
  125. package/src/ssr/CloudShell.island.tsx +19 -3
  126. package/src/ssr/Layout.tsx +1 -1
  127. package/src/ssr/MobileProfileActions.tsx +2 -2
  128. package/src/ssr/ProfilePreferences.tsx +2 -1
  129. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  130. package/src/ssr/admin-navigation.ts +1 -11
  131. package/src/ssr/app-launchpad.tsx +6 -1
  132. package/src/ssr/platform-messages.ts +2 -0
  133. package/src/ssr/profile-actions.ts +3 -6
  134. package/src/ssr/shell-data.tsx +1 -1
  135. package/src/styles/global.css +2 -0
  136. package/src/workflows/language/expressions.ts +3 -2
  137. package/src/workflows/runtime/executor.ts +2 -1
@@ -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
+ }
@@ -1,5 +1,14 @@
1
1
  import { type SQL, sql } from "bun";
2
- import type { BaseUser, MutationResult, PaginationResponse, Role, User, UserProfile, UserProvider } from "../../contracts/shared";
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 { groupsAdmin } = await getFreeIpaConfig();
230
- const [row] = await sql<DbRow[]>`SELECT u.provider, u.profile,
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
- /** Technical membership reads stay complete; human-facing lists exclude personal groups explicitly. */
351
- export const getGroups = async (params: { id: string; recursive?: boolean; includePersonal?: boolean }): Promise<string[]> => {
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 (${params.includePersonal !== false} OR NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id))
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 (${params.includePersonal !== false} OR NOT EXISTS (SELECT 1 FROM auth.user_posix p WHERE p.primary_group_id = g.id))
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);
@@ -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
+ };
@@ -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 = 'resource_bound'
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 = 'resource_bound'
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
@@ -14,6 +14,7 @@ const operationSchema = z.union([
14
14
  z.literal("search.query"),
15
15
  z.literal("runtime.http"),
16
16
  z.literal("runtime.database"),
17
+ z.literal("workflow.run"),
17
18
  z.string().refine((value) => {
18
19
  const [prefix, localId, extra] = value.split(":");
19
20
  return (
@@ -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,
@@ -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 session). The mobile app's session is unaffected. */
652
- delete: async (c: Context): Promise<void> => {
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
- deleteCookie(c, "session_token", { path: "/" });
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,
@@ -6,6 +6,7 @@ export const EMAIL_HTML_TAGS = [
6
6
  "br",
7
7
  "code",
8
8
  "div",
9
+ "del",
9
10
  "em",
10
11
  "h1",
11
12
  "h2",
@@ -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