@k2b/cloud 0.40.0 → 0.42.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 (279) hide show
  1. package/package.json +9 -5
  2. package/src/_internal/bounded-json.ts +14 -2
  3. package/src/_internal/capabilities.ts +87 -8
  4. package/src/_internal/capability-reviews.ts +6 -0
  5. package/src/_internal/capability-sentences.ts +2 -1
  6. package/src/_internal/capability-timeout.ts +71 -0
  7. package/src/_internal/define-app.ts +158 -92
  8. package/src/_internal/heartbeat.ts +203 -14
  9. package/src/_internal/http-drain.ts +56 -0
  10. package/src/_internal/registry-validation.ts +3 -0
  11. package/src/_internal/registry.ts +28 -1
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/setup-lock.ts +103 -0
  14. package/src/_internal/status-preserving-ssr.ts +2 -0
  15. package/src/access/PermissionEditor.tsx +16 -17
  16. package/src/access/PrincipalPicker.tsx +45 -10
  17. package/src/access/messages.ts +8 -0
  18. package/src/access/principal-label.ts +18 -0
  19. package/src/access/ui.ts +1 -0
  20. package/src/account/EntitySearch.tsx +5 -4
  21. package/src/ai/action-review.ts +2 -1
  22. package/src/ai/app-skill-store.ts +1 -1
  23. package/src/ai/capabilities.ts +21 -3
  24. package/src/ai/capability-execution.ts +14 -12
  25. package/src/ai/chat/action-card.ts +76 -4
  26. package/src/ai/chat/action-messages.ts +14 -0
  27. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  28. package/src/ai/chat/action-question.tsx +214 -23
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/tool-action-words.ts +31 -0
  31. package/src/ai/chat/tool-action.tsx +1 -26
  32. package/src/ai/chat-tasks.ts +2 -1
  33. package/src/ai/client/controller.ts +150 -32
  34. package/src/ai/client/transport.ts +14 -2
  35. package/src/ai/code-capability-routes.ts +27 -23
  36. package/src/ai/code-runtime-tools.ts +14 -16
  37. package/src/ai/code-source-tools.ts +16 -18
  38. package/src/ai/executor.ts +64 -21
  39. package/src/ai/fetch-file-tool.ts +67 -76
  40. package/src/ai/firecrawl-tools.ts +15 -2
  41. package/src/ai/http.ts +1 -0
  42. package/src/ai/index.ts +1 -0
  43. package/src/ai/migrate.ts +28 -0
  44. package/src/ai/open-questions.ts +168 -0
  45. package/src/ai/projects.ts +1 -2
  46. package/src/ai/protocol.ts +2 -2
  47. package/src/ai/question-text.ts +8 -0
  48. package/src/ai/recent-resources.ts +12 -5
  49. package/src/ai/review-content.ts +77 -0
  50. package/src/ai/routes.ts +119 -35
  51. package/src/ai/runtime.ts +1 -0
  52. package/src/ai/skill-tool.ts +17 -6
  53. package/src/ai/skills-routes.ts +72 -27
  54. package/src/ai/skills.ts +139 -60
  55. package/src/ai/store.ts +184 -31
  56. package/src/ai/testing.ts +36 -0
  57. package/src/ai/tool-sentences.ts +152 -34
  58. package/src/ai/tool-wording.ts +12 -6
  59. package/src/ai/tools.ts +8 -3
  60. package/src/ai/types.ts +25 -4
  61. package/src/ai/usage.ts +2 -2
  62. package/src/api/admin-core-settings.ts +15 -2
  63. package/src/api/admin-geo.ts +54 -0
  64. package/src/api/admin-linux-identities.ts +6 -3
  65. package/src/api/admin-outgoing-mail.ts +29 -15
  66. package/src/api/app-approval-actions.ts +31 -7
  67. package/src/api/approvals-loader.ts +3 -3
  68. package/src/api/approvals.ts +51 -0
  69. package/src/api/apps.ts +4 -2
  70. package/src/api/auth/schemas.ts +3 -0
  71. package/src/api/auth.ts +54 -18
  72. package/src/api/capabilities.ts +258 -181
  73. package/src/api/capability-streams.ts +21 -13
  74. package/src/api/cli-plugins.ts +4 -1
  75. package/src/api/geo-glyphs.ts +11 -0
  76. package/src/api/geo.ts +128 -0
  77. package/src/api/help.ts +12 -4
  78. package/src/api/index.ts +5 -0
  79. package/src/api/mcp.ts +73 -41
  80. package/src/api/me-panel-consent.ts +8 -5
  81. package/src/api/me.ts +79 -3
  82. package/src/api/search.ts +21 -14
  83. package/src/api/sync-ops.ts +19 -13
  84. package/src/api/widgets.ts +42 -18
  85. package/src/approvals/Approvals.tsx +30 -66
  86. package/src/approvals/approval-card.ts +68 -0
  87. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  88. package/src/browser/CloudResourceSearch.tsx +3 -13
  89. package/src/browser/PanelLeaveNotice.tsx +18 -0
  90. package/src/browser/navigation-search.ts +78 -25
  91. package/src/browser/panel-leave.ts +69 -0
  92. package/src/browser/panel-messages.ts +12 -0
  93. package/src/browser/panel-slot.ts +173 -24
  94. package/src/browser/panel.ts +3 -0
  95. package/src/browser/resource-search-messages.ts +2 -0
  96. package/src/capabilities/client.ts +28 -1
  97. package/src/capabilities/executions.ts +22 -3
  98. package/src/capabilities/geo-client.ts +57 -0
  99. package/src/capabilities/response.ts +2 -1
  100. package/src/capabilities/review-storage.ts +24 -0
  101. package/src/capabilities/reviews.ts +17 -3
  102. package/src/capabilities/server.ts +36 -16
  103. package/src/cli/access.ts +15 -23
  104. package/src/cli/admin/linux.ts +13 -2
  105. package/src/cli/admin/outgoing-mail.ts +26 -4
  106. package/src/cli/capabilities.ts +19 -0
  107. package/src/config/env.ts +0 -6
  108. package/src/contracts/app-access.ts +48 -0
  109. package/src/contracts/app-approval.ts +3 -3
  110. package/src/contracts/app.ts +4 -1
  111. package/src/contracts/approvals.ts +2 -2
  112. package/src/contracts/avatar.ts +186 -0
  113. package/src/contracts/capabilities.ts +149 -55
  114. package/src/contracts/geo-admin.ts +127 -0
  115. package/src/contracts/geo-map.ts +19 -0
  116. package/src/contracts/geo-route.ts +168 -0
  117. package/src/contracts/geo.ts +248 -0
  118. package/src/contracts/index.ts +4 -0
  119. package/src/contracts/outgoing-mail.ts +17 -4
  120. package/src/contracts/registry.ts +2 -0
  121. package/src/contracts/shared.ts +3 -0
  122. package/src/events/app-access.ts +68 -0
  123. package/src/events/live-delivery.ts +128 -0
  124. package/src/events/live.ts +14 -130
  125. package/src/geo/CloudMap.tsx +95 -0
  126. package/src/geo/map-messages.ts +17 -0
  127. package/src/geo/ui.ts +1 -0
  128. package/src/index.ts +2 -0
  129. package/src/server/client-ip.ts +36 -0
  130. package/src/server/index.ts +3 -4
  131. package/src/server/middleware/app-access.ts +312 -0
  132. package/src/server/middleware/auth.ts +5 -3
  133. package/src/server/middleware/invocation.ts +11 -0
  134. package/src/server/middleware/rate-limit.ts +4 -4
  135. package/src/server/services/access.ts +13 -9
  136. package/src/server/services/index.ts +0 -2
  137. package/src/server/services/services.ts +0 -2
  138. package/src/services/accounts/app.ts +23 -3
  139. package/src/services/accounts/posix.ts +21 -7
  140. package/src/services/accounts/users.ts +12 -1
  141. package/src/services/allowed-network-fetch.ts +16 -10
  142. package/src/services/app-access-metrics.ts +24 -0
  143. package/src/services/app-access.ts +123 -0
  144. package/src/services/app-discovery.ts +27 -0
  145. package/src/services/approvals/content.ts +40 -0
  146. package/src/services/approvals/device.ts +49 -33
  147. package/src/services/approvals/index.ts +15 -6
  148. package/src/services/approvals/list.ts +6 -11
  149. package/src/services/approvals/question.ts +72 -0
  150. package/src/services/audit/index.ts +1 -0
  151. package/src/services/auth-flows/ipa.ts +39 -2
  152. package/src/services/auth-flows/password-reset.ts +32 -6
  153. package/src/services/downstream-budget.ts +87 -0
  154. package/src/services/geo/admin.ts +262 -0
  155. package/src/services/geo/bucket.ts +67 -0
  156. package/src/services/geo/cache.ts +57 -0
  157. package/src/services/geo/costs.ts +5 -0
  158. package/src/services/geo/errors.ts +41 -0
  159. package/src/services/geo/fixtures/README.md +17 -0
  160. package/src/services/geo/fixtures/along.json +355 -0
  161. package/src/services/geo/fixtures/autocomplete.json +218 -0
  162. package/src/services/geo/fixtures/geocode.json +129 -0
  163. package/src/services/geo/fixtures/places.json +355 -0
  164. package/src/services/geo/fixtures/reverse.json +61 -0
  165. package/src/services/geo/fixtures/routing.json +57 -0
  166. package/src/services/geo/geoapify-route.ts +93 -0
  167. package/src/services/geo/geoapify.ts +281 -0
  168. package/src/services/geo/ledger.ts +122 -0
  169. package/src/services/geo/map.ts +160 -0
  170. package/src/services/geo/migrate.ts +25 -0
  171. package/src/services/geo/polyline.ts +184 -0
  172. package/src/services/geo/route-chain.ts +145 -0
  173. package/src/services/geo/route-costs.ts +9 -0
  174. package/src/services/geo/route-geometry.ts +39 -0
  175. package/src/services/geo/route-plan.ts +172 -0
  176. package/src/services/geo/route-service.ts +433 -0
  177. package/src/services/geo/service.ts +300 -0
  178. package/src/services/geo/settings.ts +49 -0
  179. package/src/services/help/index.ts +16 -7
  180. package/src/services/help/types.ts +6 -1
  181. package/src/services/identity/invocation-actor.ts +20 -2
  182. package/src/services/identity/issue-invocation.ts +67 -0
  183. package/src/services/identity/key-ring.ts +5 -3
  184. package/src/services/identity/runtime-config.ts +23 -0
  185. package/src/services/index.ts +11 -0
  186. package/src/services/mandates/index.ts +40 -3
  187. package/src/services/network-security.ts +6 -3
  188. package/src/services/network-transport.ts +99 -0
  189. package/src/services/notifications/catalog.ts +3 -2
  190. package/src/services/notifications/runtime.ts +1 -0
  191. package/src/services/notifications/web-push-transport.ts +45 -38
  192. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  193. package/src/services/outgoing-mail/index.ts +55 -1
  194. package/src/services/outgoing-mail/messages.ts +82 -3
  195. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  196. package/src/services/outgoing-mail/retention.ts +13 -1
  197. package/src/services/outgoing-mail/store.ts +36 -6
  198. package/src/services/outgoing-mail/test-send.ts +34 -4
  199. package/src/services/outgoing-mail/transport.ts +1 -1
  200. package/src/services/pdf/gotenberg.ts +11 -38
  201. package/src/services/pdf/markdown.ts +160 -2
  202. package/src/services/pdf/template-preview.ts +1 -1
  203. package/src/services/public-http.ts +28 -40
  204. package/src/services/session/index.ts +203 -29
  205. package/src/services/settings/app.ts +4 -1
  206. package/src/services/settings/core-settings.ts +88 -0
  207. package/src/services/settings/defaults.ts +8 -2
  208. package/src/services/settings/errors.ts +11 -0
  209. package/src/services/settings/geo-budget.ts +40 -0
  210. package/src/services/settings/index.ts +4 -3
  211. package/src/services/settings/store.ts +25 -17
  212. package/src/services/weather/geo.ts +29 -111
  213. package/src/services/weather/index.ts +4 -3
  214. package/src/services/weather/locations.ts +8 -5
  215. package/src/services/weather/migrate.ts +1 -0
  216. package/src/services/weather/settings.ts +0 -16
  217. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  218. package/src/shared/avatar-art.ts +400 -0
  219. package/src/shared/index.ts +2 -0
  220. package/src/shared/network-address.ts +22 -3
  221. package/src/shared/template-rendering.ts +36 -4
  222. package/src/shared-text/documents.ts +1 -1
  223. package/src/shared-text/index.ts +2 -0
  224. package/src/shared-text/log.ts +51 -3
  225. package/src/shared-text/snapshots.ts +340 -0
  226. package/src/ssr/AdminLayout.tsx +4 -2
  227. package/src/ssr/CloudShell.island.tsx +6 -0
  228. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  229. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  230. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  231. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  232. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  233. package/src/ssr/HelpPanelView.island.tsx +27 -0
  234. package/src/ssr/Layout.tsx +65 -15
  235. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  236. package/src/ssr/LayoutHelp.tsx +185 -542
  237. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
  238. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  239. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  240. package/src/ssr/PanelRuntime.island.tsx +11 -7
  241. package/src/ssr/PwaLayout.tsx +2 -2
  242. package/src/ssr/RailApps.tsx +6 -0
  243. package/src/ssr/ShellRuntime.island.tsx +23 -7
  244. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  245. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  246. package/src/ssr/admin-navigation.ts +1 -0
  247. package/src/ssr/app-badges.ts +6 -1
  248. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  249. package/src/ssr/help.ts +7 -5
  250. package/src/ssr/index.ts +2 -1
  251. package/src/ssr/islands/index.ts +3 -4
  252. package/src/ssr/layout-context.ts +2 -0
  253. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  254. package/src/ssr/layout-help-panel-document.ts +67 -0
  255. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  256. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  257. package/src/ssr/layout-help-panel-modules.ts +7 -0
  258. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  259. package/src/ssr/layout-help-panel-search.tsx +69 -0
  260. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  261. package/src/ssr/layout-help-panel-state.ts +141 -0
  262. package/src/ssr/layout-help-panel-types.ts +19 -0
  263. package/src/ssr/layout-help-views.tsx +484 -0
  264. package/src/ssr/mobile-menu-history.ts +4 -0
  265. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  266. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  267. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  268. package/src/ssr/page-swap.ts +28 -0
  269. package/src/ssr/page-trail-runtime.ts +32 -13
  270. package/src/ssr/panel-consent.ts +2 -2
  271. package/src/ssr/panel-idle.ts +9 -0
  272. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  273. package/src/ssr/platform-messages.ts +8 -0
  274. package/src/ssr/runtime.ts +17 -0
  275. package/src/ssr/shell-data.tsx +10 -2
  276. package/src/styles/approvals.css +6 -0
  277. package/src/styles/effects.css +15 -0
  278. package/src/styles/global.css +70 -6
  279. package/src/server/services/geo.ts +0 -215
@@ -0,0 +1,24 @@
1
+ import { logger } from "./logging";
2
+ import { requestCacheRedis } from "./request-cache-redis";
3
+
4
+ const KEY = "cloud:app-access:denied";
5
+ let lastFailureLog = -Infinity;
6
+
7
+ /** Count denied entry/invocation attempts, never discovery filtering or audit rows. Call only for registered app IDs. */
8
+ export const recordAppAccessDenied = (appId: string): void => {
9
+ void requestCacheRedis()
10
+ .then((redis) => redis.send("HINCRBY", [KEY, appId, "1"]))
11
+ .catch(() => {
12
+ if (Date.now() - lastFailureLog < 60_000) return;
13
+ lastFailureLog = Date.now();
14
+ logger("app-access").warn("Application access denial metrics are unavailable");
15
+ });
16
+ };
17
+
18
+ /** The metric collector reads only the current registry's bounded set of app labels. */
19
+ export const readAppAccessDenials = async (appIds: readonly string[]): Promise<{ app: string; count: number }[]> => {
20
+ if (!appIds.length) return [];
21
+ const values: unknown = await (await requestCacheRedis()).send("HMGET", [KEY, ...appIds]);
22
+ if (!Array.isArray(values)) throw new Error("Invalid application access metrics response");
23
+ return appIds.map((app, index) => ({ app, count: Number(values[index] ?? 0) }));
24
+ };
@@ -0,0 +1,123 @@
1
+ import { type SQL, sql } from "bun";
2
+ import type { Context } from "hono";
3
+ import type { AppMeta } from "../contracts/app";
4
+ import { AppAccessRuleSchema, type FixedAppAccessRule } from "../contracts/app-access";
5
+ import { userFromActor } from "../server/actor";
6
+ import type { AuthContext, RequestAuthority } from "../server/middleware/auth";
7
+ import { buildAccessPrincipalCondition } from "../server/services/access";
8
+ import { logger } from "./logging";
9
+ import { toPgTextArray } from "./postgres";
10
+
11
+ type AccessApp = Pick<AppMeta, "id" | "access">;
12
+ type GrantState = { app_id: string; configured: boolean; permission: "read" | "admin" | null };
13
+
14
+ /** Read the already resolved identity; never reload unrestricted roles from its subject ID. */
15
+ export const appAccessAuthority = <E extends AuthContext>(c: Context<E>): RequestAuthority | null => {
16
+ const actor = c.get("actor");
17
+ const accessSubject = c.get("accessSubject");
18
+ const credentialKind = c.get("credentialKind");
19
+ if (!actor || !accessSubject || !credentialKind) return null;
20
+ return { actor, accessSubject, credentialKind, scopes: c.get("credentialScopes") ?? [] };
21
+ };
22
+
23
+ const credentialAllows = (authority: RequestAuthority, appId: string): boolean => {
24
+ const actor = authority.actor;
25
+ if (actor.kind === "service_account") {
26
+ if (actor.serviceAccount.status !== "active") return false;
27
+ if (actor.serviceAccount.kind === "resource_bound" && actor.serviceAccount.appId !== appId) return false;
28
+ if (actor.credentialExpiresAt && !(new Date(actor.credentialExpiresAt).getTime() > Date.now())) return false;
29
+ }
30
+ const credentialKind = authority.credentialKind === "invocation" ? actor.delegation?.credentialKind : authority.credentialKind;
31
+ if (authority.scopes.includes("ai:inference")) return false;
32
+ // Full personal API keys intentionally carry no scopes. Resource/standalone
33
+ // keys retain their caps; app-prefixed scopes are already used by applications.
34
+ if (credentialKind === "session" || credentialKind === "mandate") return true;
35
+ if (
36
+ credentialKind === "api_key" &&
37
+ actor.kind === "service_account" &&
38
+ actor.serviceAccount.kind === "user_delegated" &&
39
+ authority.scopes.length === 0
40
+ )
41
+ return true;
42
+ return authority.scopes.some((scope) =>
43
+ ["read", "write", "admin", `${appId}:read`, `${appId}:write`, `${appId}:admin`, `${appId}:*`].includes(scope),
44
+ );
45
+ };
46
+
47
+ const fixedAllows = (authority: RequestAuthority, rule: FixedAppAccessRule): boolean => {
48
+ const user = userFromActor(authority.actor);
49
+ switch (rule.kind) {
50
+ case "signedIn":
51
+ return true;
52
+ case "noGuests":
53
+ return user?.profile !== "guest";
54
+ case "roles":
55
+ return !!user && rule.roles.some((role) => (role === "guest" ? user.profile === "guest" : user.roles.includes(role)));
56
+ default:
57
+ return false;
58
+ }
59
+ };
60
+
61
+ /** Pure decision shared by single-app and batched reads. Missing grant state means unconfigured, not an implicit grant. */
62
+ export const evaluateAppAccess = (authority: RequestAuthority | null, app: AccessApp, state?: GrantState): boolean => {
63
+ if (app.access === undefined) return true; // Step 1 compatibility; no inferred entry rule.
64
+ if (!authority || !credentialAllows(authority, app.id)) return false;
65
+ const parsed = AppAccessRuleSchema.safeParse(app.access);
66
+ if (!parsed.success) return false;
67
+ const rule = parsed.data;
68
+ if (rule.kind !== "grants") return fixedAllows(authority, rule);
69
+ if (!state?.configured) return !!rule.until && fixedAllows(authority, rule.until);
70
+ return state.permission === "read" || state.permission === "admin";
71
+ };
72
+
73
+ let lastFailureLog = -Infinity;
74
+ const reportFailure = () => {
75
+ if (Date.now() - lastFailureLog < 60_000) return;
76
+ lastFailureLog = Date.now();
77
+ logger("app-access").error("Application access could not be resolved; configurable applications are unavailable");
78
+ };
79
+
80
+ /**
81
+ * One authoritative SQL read for all configurable apps. No grant cache: revocation applies on the next request.
82
+ * Pass the reserved transaction as `db` when checking access inside a guarded operation.
83
+ */
84
+ export const usableAppIds = async (
85
+ authority: RequestAuthority | null,
86
+ apps: readonly AccessApp[],
87
+ options: { db?: SQL } = {},
88
+ ): Promise<Set<string>> => {
89
+ const db = options.db ?? sql;
90
+ const allowed = new Set(apps.filter((app) => app.access?.kind !== "grants" && evaluateAppAccess(authority, app)).map((app) => app.id));
91
+ if (!authority) return allowed;
92
+ const configurable = apps.filter((app) => app.access?.kind === "grants" && credentialAllows(authority, app.id));
93
+ if (configurable.length === 0) return allowed;
94
+ try {
95
+ const principal = buildAccessPrincipalCondition({
96
+ subject: authority.accessSubject,
97
+ columns: {
98
+ userId: db`a.user_id`,
99
+ groupId: db`a.group_id`,
100
+ serviceAccountId: db`a.service_account_id`,
101
+ authenticatedOnly: db`a.authenticated_only`,
102
+ },
103
+ });
104
+ const states = await db<GrantState[]>`
105
+ SELECT requested.app_id, s.configured_at IS NOT NULL AS configured,
106
+ (SELECT a.permission FROM auth.app_access j JOIN auth.access a ON a.id = j.access_id
107
+ WHERE j.app_id = requested.app_id AND a.permission IN ('read', 'admin')
108
+ AND (a.user_id IS NOT NULL OR a.group_id IS NOT NULL OR a.service_account_id IS NOT NULL OR a.authenticated_only)
109
+ AND ${principal}
110
+ ORDER BY CASE a.permission WHEN 'admin' THEN 2 ELSE 1 END DESC LIMIT 1) AS permission
111
+ FROM unnest(${toPgTextArray(configurable.map((app) => app.id))}::text[]) AS requested(app_id)
112
+ LEFT JOIN auth.app_access_state s ON s.app_id = requested.app_id
113
+ `;
114
+ const byApp = new Map(states.map((state) => [state.app_id, state]));
115
+ for (const app of configurable) if (evaluateAppAccess(authority, app, byApp.get(app.id))) allowed.add(app.id);
116
+ } catch {
117
+ reportFailure();
118
+ }
119
+ return allowed;
120
+ };
121
+
122
+ export const canUseApp = async (authority: RequestAuthority | null, app: AccessApp, options: { db?: SQL } = {}): Promise<boolean> =>
123
+ (await usableAppIds(authority, [app], options)).has(app.id);
@@ -0,0 +1,27 @@
1
+ import { listApps } from "../_internal/registry";
2
+ import type { AppMeta } from "../contracts/app";
3
+ import type { AppRegistryEntry } from "../contracts/registry";
4
+ import type { RequestAuthority } from "../server/middleware/auth";
5
+ import { usableAppIds } from "./app-access";
6
+
7
+ /** Filter before projecting any app-owned names, schemas, or content. */
8
+ export const discoverableApps = async <T extends Pick<AppMeta, "id" | "access">>(
9
+ authority: RequestAuthority | null,
10
+ apps: readonly T[],
11
+ ): Promise<T[]> => {
12
+ const usable = await usableAppIds(authority, apps);
13
+ return apps.filter((app) => usable.has(app.id));
14
+ };
15
+
16
+ /** Capability and widget records carry an app ID; the live registry owns their access declaration. */
17
+ export const discoverableAppEntries = async <T extends { appId: string }>(
18
+ authority: RequestAuthority | null,
19
+ entries: readonly T[],
20
+ registry: () => Promise<AppRegistryEntry[]> = listApps,
21
+ ): Promise<T[]> => {
22
+ if (!entries.length) return [];
23
+ const ids = new Set(entries.map((entry) => entry.appId));
24
+ const apps = (await registry()).filter((app) => ids.has(app.id));
25
+ const usable = await usableAppIds(authority, apps);
26
+ return entries.filter((entry) => usable.has(entry.appId));
27
+ };
@@ -0,0 +1,40 @@
1
+ import { sql } from "bun";
2
+ import { parseStoredReviewContent } from "../../capabilities/review-storage";
3
+ import { CAPABILITY_MAX_REVIEW_CONTENT_BYTES } from "../../contracts/capabilities";
4
+
5
+ // Start with this person's indexed pending rows; a Studio page alone cannot continue a chat.
6
+ export const openRequests = (approverUserId: string, id?: string) => sql`SELECT r.* FROM approvals.requests r
7
+ WHERE r.approver_user_id=${approverUserId}::uuid AND r.status='pending' AND r.expires_at > now()
8
+ AND r.approver_auth_epoch=(SELECT auth_epoch FROM auth.users WHERE id=${approverUserId}::uuid)
9
+ AND (${id ?? null}::uuid IS NULL OR r.id=${id ?? null}::uuid)
10
+ AND EXISTS (SELECT 1 FROM ai.pending_actions p WHERE p.approval_id=r.id AND p.status='pending')`;
11
+
12
+ /** Keep the original approval's digest and reader even when the turn has moved to a newer question. */
13
+ export const compactApprovalDetailsSql = (row = "r") => {
14
+ const details = sql`${sql(`${row}.details`)}`;
15
+ return sql`CASE WHEN jsonb_typeof(${details}) = 'object' THEN (${details} - 'content') || CASE WHEN ${details} ? 'content' THEN jsonb_build_object(
16
+ 'contentRef', jsonb_build_object('href', '/api/approvals/' || ${sql(`${row}.id`)}::text || '/content', 'digest', ${sql(`${row}.review_digest`)})
17
+ ) ELSE '{}'::jsonb END ELSE ${details} END`;
18
+ };
19
+
20
+ /** Open requests retain the existing approver, pending-action, expiry and sign-out-epoch requirements. */
21
+ export const readApprovalContent = async (input: { id: string; approverUserId: string; digest: string; signal?: AbortSignal }) => {
22
+ input.signal?.throwIfAborted();
23
+ const query = sql<{ content: unknown }[]>`
24
+ SELECT r.details->'content' AS content FROM (${openRequests(input.approverUserId, input.id)}) r
25
+ WHERE r.review_digest=${input.digest}
26
+ AND octet_length((r.details->'content')::text) <= ${2 * CAPABILITY_MAX_REVIEW_CONTENT_BYTES + 2} LIMIT 1`;
27
+ const abort = () => query.cancel();
28
+ input.signal?.addEventListener("abort", abort, { once: true });
29
+ if (input.signal?.aborted) abort();
30
+ let rows: { content: unknown }[];
31
+ try {
32
+ rows = await query;
33
+ } finally {
34
+ input.signal?.removeEventListener("abort", abort);
35
+ }
36
+ input.signal?.throwIfAborted();
37
+ const row = rows[0];
38
+ const parsed = parseStoredReviewContent(row?.content);
39
+ return parsed.success ? parsed.data : null;
40
+ };
@@ -1,4 +1,6 @@
1
- import { capabilityReviewView } from "../../_internal/capability-reviews";
1
+ import { listApps } from "../../_internal/registry";
2
+ import type { ActionCard } from "../../ai/chat/action-card";
3
+ import { type ActionQuestionText, actionCardText, actionNoteLabel } from "../../ai/chat/action-text";
2
4
  import {
3
5
  APP_APPROVAL_LIMITS,
4
6
  type AppActionSummary,
@@ -6,9 +8,12 @@ import {
6
8
  appActionDigest,
7
9
  APP_ACTION_LIMITS as limits,
8
10
  } from "../../contracts/app-approval";
9
- import { CapabilityActionReviewDataSchema } from "../../contracts/capabilities";
10
11
  import { DEFAULT_LOCALE } from "../../server/locale";
12
+ import { resolveAppIdentityPresentation } from "../../shared/app-presentation";
13
+ import { get } from ".";
11
14
  import type { PendingApprovalRow } from "./list";
15
+ import { listPending } from "./list";
16
+ import { type ApprovalActionPolicy, loadApprovalActionPolicies, pendingApprovalCard } from "./question";
12
17
 
13
18
  const plain = (value: string): string =>
14
19
  value
@@ -22,12 +27,26 @@ const cut = (value: string, limit: number): string => {
22
27
  return points.length > limit ? `${points.slice(0, limit - 1).join("")}…` : value;
23
28
  };
24
29
 
30
+ export { pendingApprovalCard };
31
+
25
32
  export const appApprovalText = (value: string, limit: number): string => cut(plain(value), limit);
26
33
 
34
+ /**
35
+ * Incomplete sends must be decided with the complete review in the browser. Without the declared policy Cloud cannot
36
+ * rule out `sends`, so an incomplete question then stays in the browser too.
37
+ */
38
+ export const appActionOffered = (view: AppActionView, question: ActionCard | ActionQuestionText, policy?: ApprovalActionPolicy): boolean =>
39
+ !(
40
+ view.truncated &&
41
+ (policy === null ||
42
+ ("reasonCode" in question ? question.reasonCode : question.reason.code) === "sends" ||
43
+ (policy && "ask" in policy && policy.ask === "sends"))
44
+ );
45
+
27
46
  /** A deterministic, plain-text snapshot; the digest binds the device's choice to everything it shows. */
28
47
  export const appActionView = async (
29
48
  row: PendingApprovalRow,
30
- options: { issuer: string; appName: (appId: string, locale: string) => string },
49
+ options: { issuer: string; appName: (appId: string, locale: string) => string; policy?: ApprovalActionPolicy },
31
50
  ): Promise<AppActionView> => {
32
51
  let truncated = false;
33
52
  const text = (value: string, limit: number) => {
@@ -44,41 +63,20 @@ export const appActionView = async (
44
63
  // The snapshot, so renaming the chat does not change what the device signs.
45
64
  title: text(row.requesterTitle ?? "", limits.titleChars) || null,
46
65
  };
47
- const lines = row.sentence.split(/\r?\n/).map(plain).filter(Boolean);
48
- const first = lines[0] ?? plain(row.action);
49
- const prefix = `${appName}: `;
50
- const sentence = first.startsWith(prefix) && first.length > prefix.length ? first.slice(prefix.length) : first;
51
- const review = CapabilityActionReviewDataSchema.safeParse(row.details);
52
- let message: string | null;
66
+ const question = actionCardText(pendingApprovalCard(row, options.appName, options.policy));
67
+ truncated ||= question.incomplete;
53
68
  const details: AppActionView["details"] = [];
54
- const detail = (label: string, value: string, format?: "date" | "date-time") => {
69
+ for (const field of question.rows) {
55
70
  if (details.length >= limits.details) {
56
71
  truncated = true;
57
- return;
72
+ break;
58
73
  }
59
- const cleanedLabel = text(label, limits.labelChars);
60
- if (!cleanedLabel) return;
61
- details.push({ label: cleanedLabel, value: text(value, limits.valueChars), ...(format ? { format } : {}) });
62
- };
63
- const messages: string[] = [];
64
- const reviewLines = new Set<string>();
65
- if (review.success) {
66
- const view = capabilityReviewView(review.data, { timeZone: "UTC" });
67
- const cleaned = plain(view.message);
68
- if (cleaned !== sentence) messages.push(cleaned);
69
- reviewLines.add(cleaned);
70
- for (const line of view.message.split(/\r?\n/)) reviewLines.add(plain(line));
71
- for (const field of view.details ?? [])
72
- detail(field.label, field.value, field.format === "date" || field.format === "date-time" ? field.format : undefined);
73
- }
74
- for (const line of lines.slice(1)) {
75
- if (reviewLines.has(line)) continue;
76
- const match = /^([^:]{1,40}):\s+(.+)$/.exec(line);
77
- if (match) detail(match[1]!, match[2]!);
78
- else messages.push(line);
74
+ // The device protocol requires labels; a line an old review left unlabelled is shown as a note, not dropped.
75
+ const label = text(field.label, limits.labelChars) || text(actionNoteLabel(question.locale), limits.labelChars);
76
+ details.push({ label, value: text(field.value, limits.valueChars), ...(field.format ? { format: field.format } : {}) });
79
77
  }
80
- message = text(messages.join(" "), limits.messageChars) || null;
81
- const boundedSentence = text(sentence, limits.sentenceChars);
78
+ const message = text(question.reason, limits.messageChars) || null;
79
+ const boundedSentence = text(question.question, limits.sentenceChars);
82
80
  const view: AppActionView = {
83
81
  actionId: row.id,
84
82
  app,
@@ -114,3 +112,21 @@ export const appActionSummary = (view: AppActionView): AppActionSummary => ({
114
112
  createdAt: view.createdAt,
115
113
  expiresAt: view.expiresAt,
116
114
  });
115
+
116
+ /** Whether a pending approval can wake a device under the same mapping used by its inbox and detail view. */
117
+ export const deviceOffersApproval = async (approvalId: string): Promise<boolean> => {
118
+ const request = await get(approvalId);
119
+ if (!request) return false;
120
+ const { items } = await listPending({ approverUserId: request.approverUserId, id: approvalId, limit: 1 });
121
+ const row = items[0];
122
+ if (!row) return false;
123
+ const apps = await listApps();
124
+ const appName = (id: string, locale: string) => {
125
+ const app = apps.find((entry) => entry.id === id);
126
+ return app ? resolveAppIdentityPresentation(app, locale).name : id;
127
+ };
128
+ const policy = (await loadApprovalActionPolicies([row]))(row);
129
+ // Offering does not sign a decision; the issuer has no effect on truncation or eligibility.
130
+ const view = await appActionView(row, { issuer: "", appName, policy });
131
+ return appActionOffered(view, pendingApprovalCard(row, appName, policy), policy);
132
+ };
@@ -1,14 +1,18 @@
1
1
  import { type SQL, sql } from "bun";
2
2
  import { capabilityRequestHash } from "../../capabilities/claims";
3
+ import { restoreReviewSnapshot, serializeReviewSnapshot } from "../../capabilities/review-storage";
3
4
  import { APPROVAL_ACP_CLIENT } from "../../contracts/approvals";
4
5
  import type { CapabilityActionReviewData } from "../../contracts/capabilities";
6
+ import { CapabilityActionReviewPresentationSchema } from "../../contracts/capabilities";
5
7
  import { isAppSessionActor } from "../../server/actor";
6
8
  import type { RequestActor, RequestCredentialKind } from "../../server/middleware/auth";
7
9
  import { audit } from "../audit";
8
10
  import { toPgTextArray } from "../postgres";
11
+ import { compactApprovalDetailsSql } from "./content";
9
12
 
10
13
  export { countPending, listPending, presentPendingApproval } from "./list";
11
14
  export { migrateApprovals } from "./migrate";
15
+ export { type ApprovalActionPolicy, loadApprovalActionPolicies, pendingApprovalCard } from "./question";
12
16
 
13
17
  // Derived from chat's AI_ACTION_BUDGET_MS and Studio's one-day abandonment of pending calls.
14
18
  export const APPROVAL_MAX_WAIT_MS = 24 * 60 * 60_000;
@@ -82,10 +86,10 @@ export type ApprovalRequest = {
82
86
  };
83
87
  // SQL aliases keep the public row shape identical for reads and locked transaction reads. A function, because a
84
88
  // fragment built at import time would bind the default sql handle before a test can choose its database.
85
- const columns = () => sql`id, app_id AS "appId", action, approval_scope AS "approvalScope",
89
+ const columns = (compact = false) => sql`id, app_id AS "appId", action, approval_scope AS "approvalScope",
86
90
  requester_kind AS "requesterKind", requester_ref AS "requesterRef", requester_app_id AS "requesterAppId",
87
91
  requester_title AS "requesterTitle", subject_id AS "subjectId", approver_user_id AS "approverUserId",
88
- assurance, sentence, details, args_digest AS "argsDigest", review_digest AS "reviewDigest", status,
92
+ assurance, sentence, ${compact ? compactApprovalDetailsSql("approvals.requests") : sql`details`} AS details, args_digest AS "argsDigest", review_digest AS "reviewDigest", status,
89
93
  decided_device_id AS "decidedDeviceId", approver_auth_epoch::text AS "approverAuthEpoch",
90
94
  decided_via AS "decidedVia", decided_at AS "decidedAt", consumed_at AS "consumedAt",
91
95
  created_at AS "createdAt", expires_at AS "expiresAt"`;
@@ -121,7 +125,7 @@ export const request = async (input: ApprovalRequestInput, db?: SQL): Promise<Ap
121
125
  requester_title, subject_id, approver_user_id, approver_auth_epoch, assurance, sentence, details, args_digest, review_digest, expires_at)
122
126
  VALUES (${input.appId}, ${input.action}, ${input.approvalScope ?? null}, ${input.requester.kind}, ${input.requester.ref},
123
127
  ${input.requester.appId}, ${input.requester.title ?? null}, ${input.subjectId}::uuid, ${input.approverUserId}::uuid,
124
- (SELECT auth_epoch FROM auth.users WHERE id=${input.approverUserId}::uuid), ${input.assurance}, ${input.sentence}, ${JSON.stringify(input.review ?? null)}::text::jsonb,
128
+ (SELECT auth_epoch FROM auth.users WHERE id=${input.approverUserId}::uuid), ${input.assurance}, ${input.sentence}, ${serializeReviewSnapshot(input.review ?? null)}::text::jsonb,
125
129
  ${approvalArgsDigest(input.input)}, ${approvalReviewDigest(input.review)}, now() + (${expiresInMs} * interval '1 millisecond'))
126
130
  ON CONFLICT (requester_kind, requester_ref) DO NOTHING
127
131
  RETURNING id
@@ -136,10 +140,15 @@ export const request = async (input: ApprovalRequestInput, db?: SQL): Promise<Ap
136
140
  });
137
141
  };
138
142
 
139
- export const get = async (id: string, db?: SQL): Promise<ApprovalRequest | null> =>
143
+ export const get = async (id: string, db?: SQL, compactReview = false): Promise<ApprovalRequest | null> =>
140
144
  transaction(db, async (tx) => {
141
- const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests WHERE id=${id}::uuid`;
142
- return row ?? null;
145
+ const [row] = await tx<ApprovalRequest[]>`SELECT ${columns(compactReview)} FROM approvals.requests WHERE id=${id}::uuid`;
146
+ return row
147
+ ? {
148
+ ...row,
149
+ details: row.details === null ? null : CapabilityActionReviewPresentationSchema.parse(restoreReviewSnapshot(row.details)),
150
+ }
151
+ : null;
143
152
  });
144
153
 
145
154
  export type ApprovalDecisionResult =
@@ -4,8 +4,9 @@ import { AI_WEBSITE_APPROVAL_TOOL, aiTurnAllowsRememberedApprovals } from "../..
4
4
  import type { AiTurnRunConfig } from "../../ai/types";
5
5
  import type { RuntimeAppMeta } from "../../contracts/app";
6
6
  import { APPROVALS_PAGE_SIZE, type ApprovalCounts, type PendingApproval } from "../../contracts/approvals";
7
- import { type CapabilityActionPolicy, CapabilityActionReviewDataSchema } from "../../contracts/capabilities";
7
+ import { type CapabilityActionPolicy, CapabilityActionReviewPresentationSchema } from "../../contracts/capabilities";
8
8
  import type { ApprovalAssurance } from ".";
9
+ import { compactApprovalDetailsSql, openRequests } from "./content";
9
10
  import { decodeApprovalCursor, encodeApprovalCursor } from "./cursor";
10
11
 
11
12
  export type PendingApprovalRow = {
@@ -32,12 +33,6 @@ export type PendingApprovalRow = {
32
33
  runConfig: AiTurnRunConfig | null;
33
34
  };
34
35
 
35
- // Start with this person's indexed pending rows; a Studio page alone cannot continue a chat.
36
- const openRequests = (approverUserId: string, id?: string) => sql`SELECT r.* FROM approvals.requests r
37
- WHERE r.approver_user_id=${approverUserId}::uuid AND r.status='pending' AND r.expires_at > now()
38
- AND r.approver_auth_epoch=(SELECT auth_epoch FROM auth.users WHERE id=${approverUserId}::uuid)
39
- AND (${id ?? null}::uuid IS NULL OR r.id=${id ?? null}::uuid)
40
- AND EXISTS (SELECT 1 FROM ai.pending_actions p WHERE p.approval_id=r.id AND p.status='pending')`;
41
36
  const matches = (appId?: string, link?: string) => sql`
42
37
  (${appId ?? null}::text IS NULL OR r.app_id=${appId ?? null} OR r.requester_app_id=${appId ?? null})
43
38
  AND (${link ?? null}::text IS NULL OR COALESCE(r.details->'links' @> jsonb_build_array(jsonb_build_object('href', ${link ?? null}::text)),false))`;
@@ -58,7 +53,7 @@ export const listPending = async (input: {
58
53
  SELECT count(*) FILTER (WHERE ${matches(input.appId, input.link)})::int AS count,
59
54
  count(*) FILTER (WHERE NOT (${matches(input.appId, input.link)}))::int AS elsewhere FROM open r`;
60
55
  const rows = await sql<PendingApprovalRow[]>`WITH open AS (${openRequests(input.approverUserId, input.id)})
61
- SELECT r.id, r.app_id AS "appId", r.action, r.requester_app_id AS "requesterAppId", r.requester_title AS "requesterTitle", r.assurance, r.sentence, r.details, r.args_digest AS "argsDigest",
56
+ SELECT r.id, r.app_id AS "appId", r.action, r.requester_app_id AS "requesterAppId", r.requester_title AS "requesterTitle", r.assurance, r.sentence, ${compactApprovalDetailsSql()} AS details, r.args_digest AS "argsDigest",
62
57
  r.created_at AS "createdAt", r.expires_at AS "expiresAt",
63
58
  to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS "cursorCreatedAt",
64
59
  question.short_id AS "conversationShortId", question.title AS "conversationTitle",
@@ -67,7 +62,7 @@ export const listPending = async (input: {
67
62
  question.stored_review AS "storedReview"
68
63
  FROM open r JOIN LATERAL (
69
64
  SELECT c.short_id, c.title, p.allow_chat, p.allow_always, p.remember_tool_name, p.tool_name, t.run_config,
70
- (SELECT m.meta->'toolReviews'->review.call_id FROM (
65
+ (SELECT (m.meta->'toolReviews'->review.call_id) - 'content' - 'contentRef' FROM (
71
66
  SELECT meta, conversation_id, loop_id, role, seq FROM ai.messages
72
67
  UNION ALL
73
68
  SELECT meta, conversation_id, loop_id, role, seq FROM ai.task_messages
@@ -109,12 +104,12 @@ export const presentPendingApproval = (
109
104
  policy?: CapabilityActionPolicy,
110
105
  ): PendingApproval => {
111
106
  const app = apps.find((entry) => entry.id === row.appId);
112
- const review = CapabilityActionReviewDataSchema.nullable().safeParse(row.details);
107
+ const review = CapabilityActionReviewPresentationSchema.nullable().safeParse(row.details);
113
108
  const originalReview = review.success ? review.data : null;
114
109
  const displayReview =
115
110
  originalReview && "sentence" in originalReview
116
111
  ? row.storedReview && "sentence" in row.storedReview
117
- ? row.storedReview
112
+ ? { ...originalReview, ask: originalReview.ask ?? row.storedReview.ask }
118
113
  : storedActionReview(originalReview, policy)
119
114
  : originalReview;
120
115
  const remember = row.assurance === "standard" && aiTurnAllowsRememberedApprovals(row.runConfig);
@@ -0,0 +1,72 @@
1
+ import { listCapabilities } from "../../_internal/registry";
2
+ import { type AiStoredActionReview, storedActionReview } from "../../ai/action-review";
3
+ import type { ActionCard } from "../../ai/chat/action-card";
4
+ import { approvalCard } from "../../approvals/approval-card";
5
+ import {
6
+ type CapabilityActionPolicy,
7
+ CapabilityActionReviewPresentationSchema,
8
+ capabilityActionPolicy,
9
+ } from "../../contracts/capabilities";
10
+ import { DEFAULT_LOCALE } from "../../server/locale";
11
+ import type { PendingApprovalRow } from "./list";
12
+
13
+ /**
14
+ * One open approval as the question card words it, for places outside the browser: Cloud Login and notifications.
15
+ * The approvals page builds the same card from the same rows.
16
+ */
17
+
18
+ /**
19
+ * The declared policy of a row's Action, needed for new-shape reviews' device eligibility and fallback reason:
20
+ * `undefined` when none is needed or declared, `null` when the registry could not say.
21
+ */
22
+ export type ApprovalActionPolicy = CapabilityActionPolicy | null | undefined;
23
+ const needsPolicy = (row: PendingApprovalRow): boolean => {
24
+ if (row.storedReview && "sentence" in row.storedReview) return true;
25
+ const review = CapabilityActionReviewPresentationSchema.safeParse(row.details);
26
+ return review.success && "sentence" in review.data;
27
+ };
28
+ /** Reads the registry once for the rows that need their Action's declared policy, as the approvals page does. */
29
+ export const loadApprovalActionPolicies = async (
30
+ rows: readonly PendingApprovalRow[],
31
+ ): Promise<(row: PendingApprovalRow) => ApprovalActionPolicy> => {
32
+ if (!rows.some(needsPolicy)) return () => undefined;
33
+ const capabilities = await listCapabilities().catch(() => null);
34
+ return (row) => {
35
+ if (!needsPolicy(row)) return undefined;
36
+ if (capabilities === null) return null;
37
+ // An app that is not registered right now cannot say why its Action asks either.
38
+ const manifest = capabilities.find((entry) => entry.appId === row.appId)?.manifest;
39
+ return manifest ? capabilityActionPolicy(manifest, row.action) : null;
40
+ };
41
+ };
42
+
43
+ /** The open request binds the content; the turn supplies the declared reason for older snapshots. */
44
+ export const pendingApprovalReview = (
45
+ row: Pick<PendingApprovalRow, "storedReview" | "details">,
46
+ policy?: ApprovalActionPolicy,
47
+ ): AiStoredActionReview | null => {
48
+ const parsed = CapabilityActionReviewPresentationSchema.safeParse(row.details);
49
+ if (parsed.success && "sentence" in parsed.data)
50
+ return storedActionReview(
51
+ { ...parsed.data, ask: parsed.data.ask ?? (row.storedReview && "sentence" in row.storedReview ? row.storedReview.ask : undefined) },
52
+ policy,
53
+ );
54
+ return row.storedReview ?? (parsed.success ? storedActionReview(parsed.data, policy) : null);
55
+ };
56
+
57
+ /** Resolve the recorded question in the language and time zone of its originating turn. */
58
+ export const pendingApprovalCard = (
59
+ row: PendingApprovalRow,
60
+ appName: (appId: string, locale: string) => string,
61
+ policy?: ApprovalActionPolicy,
62
+ fallbackLocale = DEFAULT_LOCALE,
63
+ ): ActionCard => {
64
+ const locale = row.runConfig?.kind === "compact" ? fallbackLocale : (row.runConfig?.locale ?? fallbackLocale);
65
+ const timeZone = row.runConfig?.kind === "compact" ? "UTC" : (row.runConfig?.timeZone ?? "UTC");
66
+ const review = pendingApprovalReview(row, policy);
67
+ return approvalCard(
68
+ { sentence: row.sentence, action: row.action, appName: appName(row.appId, locale), appIcon: null, review },
69
+ locale,
70
+ timeZone,
71
+ );
72
+ };
@@ -122,6 +122,7 @@ const SELF_SERVICE_ACTION_LABELS = {
122
122
  "webauthn_credential.authenticate": "Passkey used",
123
123
  "auth.pwa.device.enroll": "App paired",
124
124
  "auth.pwa.device.revoke": "App removed",
125
+ "auth.session.sign_out_others": "Signed out everywhere else",
125
126
  } as const satisfies Record<string, string>;
126
127
  const SELF_SERVICE_ACTIONS = Object.keys(SELF_SERVICE_ACTION_LABELS);
127
128
 
@@ -4,6 +4,7 @@ import { isAccountCategoryAllowed } from "../account-category-policy";
4
4
  import { accounts } from "../accounts";
5
5
  import { logger } from "../logging";
6
6
  import { providers } from "../providers";
7
+ import { session } from "../session";
7
8
 
8
9
  type IpaLoginFailure =
9
10
  | { ok: false; status: 403; reason: "category_disabled"; message: string }
@@ -19,6 +20,8 @@ type IpaLoginSuccess = {
19
20
  ok: true;
20
21
  userId: string;
21
22
  user: User;
23
+ /** Read before the password check; session issuance refuses a newer one. */
24
+ authEpoch: number;
22
25
  };
23
26
 
24
27
  export type IpaLoginFlowResult = IpaLoginSuccess | IpaLoginFailure;
@@ -96,6 +99,13 @@ export const login = async (params: { username: string; password: string }): Pro
96
99
  return failInvalidCredentials({ identifier: params.username, password: params.password });
97
100
  }
98
101
 
102
+ // Read before the password check so a concurrent password change or sign-out ends this sign-in too.
103
+ const [account] = await sql<Array<{ auth_epoch: string | number | bigint }>>`
104
+ SELECT auth_epoch FROM auth.users WHERE provider = 'ipa' AND uid = ${uid}
105
+ `;
106
+ const authEpoch = account ? Number(account.auth_epoch) : null;
107
+ if (authEpoch !== null && (!Number.isSafeInteger(authEpoch) || authEpoch < 0)) throw new Error("User auth_epoch is invalid");
108
+
99
109
  const loginResult = await providers.ipa.auth.login(uid, params.password).catch((error) => {
100
110
  log.error("FreeIPA login transport failed", {
101
111
  uid,
@@ -168,11 +178,21 @@ export const login = async (params: { username: string; password: string }): Pro
168
178
 
169
179
  const userResult = await loadSyncedIpaUser(uid);
170
180
  if (!userResult.ok) return userResult;
181
+ // An account synced in only after the read has no epoch this sign-in is bound to; the next attempt has one.
182
+ if (authEpoch === null) {
183
+ return {
184
+ ok: false,
185
+ status: 400,
186
+ reason: "user_not_synced",
187
+ message: "Your account is not yet available. Please try again in a few minutes.",
188
+ };
189
+ }
171
190
 
172
191
  return {
173
192
  ok: true,
174
193
  userId: userResult.userId,
175
194
  user: userResult.user,
195
+ authEpoch,
176
196
  };
177
197
  };
178
198
 
@@ -180,7 +200,11 @@ export const changeExpiredPassword = async (params: {
180
200
  username: string;
181
201
  currentPassword: string;
182
202
  newPassword: string;
183
- }): Promise<IpaLoginFlowResult | { ok: false; status: number; reason: "change_failed"; message: string }> => {
203
+ }): Promise<
204
+ | (IpaLoginSuccess & { signOutOthersFailed: boolean })
205
+ | IpaLoginFailure
206
+ | { ok: false; status: number; reason: "change_failed"; message: string }
207
+ > => {
184
208
  if (!(await isAccountCategoryAllowed({ provider: "ipa", profile: "user" })))
185
209
  return { ok: false, status: 403, reason: "category_disabled", message: "FreeIPA accounts are disabled. Contact an administrator." };
186
210
  const changeResult = await providers.ipa.auth.changeExpiredPassword(params);
@@ -193,5 +217,18 @@ export const changeExpiredPassword = async (params: {
193
217
  };
194
218
  }
195
219
 
196
- return login({ username: params.username, password: params.newPassword });
220
+ // Resolve the local account here, before the caller issues its new browser session, even if login later fails.
221
+ const uid = await resolveIpaLoginUid(params.username);
222
+ const [user] = uid ? await sql<Array<{ id: string }>>`SELECT id FROM auth.users WHERE provider = 'ipa' AND uid = ${uid}` : [];
223
+ let signOutOthersFailed = false;
224
+ if (user) {
225
+ try {
226
+ await session.endOtherSignIns(user.id, { reason: "password_change" });
227
+ } catch {
228
+ log.warn("Password changed, but ending other sign-ins failed", { userId: user.id });
229
+ signOutOthersFailed = true;
230
+ }
231
+ }
232
+ const loginResult = await login({ username: params.username, password: params.newPassword });
233
+ return loginResult.ok ? { ...loginResult, signOutOthersFailed } : loginResult;
197
234
  };