@k2b/cloud 0.40.0 → 0.41.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 (75) hide show
  1. package/package.json +6 -3
  2. package/src/_internal/capability-sentences.ts +2 -1
  3. package/src/_internal/define-app.ts +13 -3
  4. package/src/_internal/heartbeat.ts +203 -14
  5. package/src/_internal/registry.ts +28 -1
  6. package/src/_internal/setup-lock.ts +103 -0
  7. package/src/ai/chat/action-card.ts +69 -3
  8. package/src/ai/chat/action-question.tsx +12 -1
  9. package/src/ai/chat/action-text.ts +288 -0
  10. package/src/ai/chat/tool-action-words.ts +31 -0
  11. package/src/ai/chat/tool-action.tsx +1 -26
  12. package/src/ai/executor.ts +10 -2
  13. package/src/ai/firecrawl-tools.ts +15 -2
  14. package/src/ai/index.ts +1 -0
  15. package/src/ai/migrate.ts +15 -0
  16. package/src/ai/open-questions.ts +172 -0
  17. package/src/ai/question-text.ts +8 -0
  18. package/src/ai/routes.ts +51 -5
  19. package/src/ai/store.ts +18 -0
  20. package/src/ai/testing.ts +36 -0
  21. package/src/ai/tool-sentences.ts +152 -34
  22. package/src/ai/tool-wording.ts +12 -6
  23. package/src/ai/tools.ts +8 -3
  24. package/src/ai/types.ts +16 -2
  25. package/src/api/app-approval-actions.ts +31 -7
  26. package/src/api/auth/schemas.ts +3 -0
  27. package/src/api/auth.ts +54 -18
  28. package/src/api/mcp.ts +27 -3
  29. package/src/api/me.ts +79 -3
  30. package/src/approvals/Approvals.tsx +25 -63
  31. package/src/approvals/approval-card.ts +68 -0
  32. package/src/contracts/app-approval.ts +3 -3
  33. package/src/contracts/capabilities.ts +1 -1
  34. package/src/contracts/shared.ts +3 -0
  35. package/src/server/middleware/auth.ts +4 -2
  36. package/src/services/accounts/app.ts +23 -3
  37. package/src/services/accounts/users.ts +12 -1
  38. package/src/services/approvals/device.ts +49 -33
  39. package/src/services/approvals/index.ts +1 -0
  40. package/src/services/approvals/list.ts +1 -1
  41. package/src/services/approvals/question.ts +68 -0
  42. package/src/services/audit/index.ts +1 -0
  43. package/src/services/auth-flows/ipa.ts +39 -2
  44. package/src/services/auth-flows/password-reset.ts +32 -6
  45. package/src/services/index.ts +2 -0
  46. package/src/services/pdf/markdown.ts +160 -2
  47. package/src/services/session/index.ts +203 -29
  48. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  49. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  50. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  51. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  52. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  53. package/src/ssr/HelpPanelView.island.tsx +27 -0
  54. package/src/ssr/LayoutHelp.tsx +182 -540
  55. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
  56. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  57. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  58. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  59. package/src/ssr/help.ts +4 -3
  60. package/src/ssr/index.ts +1 -0
  61. package/src/ssr/islands/index.ts +3 -4
  62. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  63. package/src/ssr/layout-help-panel-document.ts +67 -0
  64. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  65. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  66. package/src/ssr/layout-help-panel-modules.ts +7 -0
  67. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  68. package/src/ssr/layout-help-panel-search.tsx +69 -0
  69. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  70. package/src/ssr/layout-help-panel-state.ts +141 -0
  71. package/src/ssr/layout-help-panel-types.ts +19 -0
  72. package/src/ssr/layout-help-views.tsx +484 -0
  73. package/src/ssr/panel-idle.ts +9 -0
  74. package/src/styles/approvals.css +6 -0
  75. package/src/styles/effects.css +15 -0
package/src/ai/types.ts CHANGED
@@ -13,7 +13,12 @@ import type {
13
13
  import type { Usage } from "@k2b/nessi/ai";
14
14
  import type { z } from "zod";
15
15
  import type { CapabilitySentenceField } from "../_internal/capability-sentences";
16
- import type { CapabilityActionReviewData, CapabilityActionSentences, CloudResourceRef } from "../contracts/capabilities";
16
+ import type {
17
+ CapabilityActionCallReview,
18
+ CapabilityActionReviewData,
19
+ CapabilityActionSentences,
20
+ CloudResourceRef,
21
+ } from "../contracts/capabilities";
17
22
  import type { RequestActor } from "../server";
18
23
  import type { AiActionCheck, AiStoredActionReview } from "./action-review";
19
24
  import type { AiLocalClient, AiLocalContext, AiLocalToolName } from "./local-tool-contracts";
@@ -451,7 +456,14 @@ export type AiPendingTurnAction =
451
456
  name: string;
452
457
  args: unknown;
453
458
  message?: string;
459
+ /** Stored review, including the declared ask reason for new-shape reviews. */
454
460
  review?: CapabilityActionReviewData;
461
+ /** Owning application, with its registry name in the turn's request locale. */
462
+ app?: { id: string; name: string };
463
+ /** The chat's current title, when known. */
464
+ chatTitle?: string | null;
465
+ /** Expiry of the platform approval request, as an ISO timestamp. */
466
+ expiresAt?: string;
455
467
  allowAlways: boolean;
456
468
  /** Whether the approval can be remembered for this chat. */
457
469
  allowChat: boolean;
@@ -1032,6 +1044,8 @@ export type AiConversationService = {
1032
1044
  getPendingTurnAction(input: { conversationId: string; turnId: string; callId: string }): Promise<AiPendingTurnActionRecord | null>;
1033
1045
  findPendingTurnActionByApproval(input: { approvalId: string; ownerUserId: string }): Promise<AiPendingTurnActionRecord | null>;
1034
1046
  listPendingActionRecords(input: { conversationId: string; turnId: string }): Promise<AiPendingTurnActionRecord[]>;
1047
+ /** The reviews Cloud stored with a turn's tool calls, keyed by call ID; the newest message wins. */
1048
+ listTurnToolReviews(input: { conversationId: string; turnId: string }): Promise<Map<string, AiStoredActionReview>>;
1035
1049
  listResolvedPendingActions(input: { conversationId: string; turnId: string }): Promise<AiPendingTurnActionRecord[]>;
1036
1050
  resolvePendingTurnAction(input: {
1037
1051
  conversationId: string;
@@ -1151,7 +1165,7 @@ export type AiToolRuntime<TInput extends z.ZodType = z.ZodType, TOutput extends
1151
1165
  /** Localized short status; never include payloads or secrets. */
1152
1166
  reportProgress?: (message: string) => Promise<void>;
1153
1167
  /** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
1154
- requestApprovalFor?: (message: string, target: AiApprovalTarget) => Promise<boolean>;
1168
+ requestApprovalFor?: (message: string, target: AiApprovalTarget, review?: CapabilityActionCallReview) => Promise<boolean>;
1155
1169
  /** Relays a decision for an effect another platform component consumes and executes. */
1156
1170
  requestRelayedApproval?: (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => Promise<boolean>;
1157
1171
  /** Shows requests a website approval let through as they go out, so the chat keeps them whatever the outcome. */
@@ -1,20 +1,44 @@
1
1
  import { submitAiTurnAction } from "../ai/runtime";
2
2
  import { aiConversations } from "../ai/store";
3
- import { APP_ACTION_LIMITS } from "../contracts/app-approval";
3
+ import { APP_ACTION_LIMITS, type AppActionSummary } from "../contracts/app-approval";
4
+ import { APPROVALS_PAGE_SIZE } from "../contracts/approvals";
4
5
  import type { AppDeviceActions } from "../services/app-approval";
5
- import { listPending } from "../services/approvals";
6
- import { appActionSummary, appActionView } from "../services/approvals/device";
6
+ import { listPending, loadApprovalActionPolicies, pendingApprovalCard } from "../services/approvals";
7
+ import { appActionOffered, appActionSummary, appActionView } from "../services/approvals/device";
8
+
9
+ /** Pages of open requests one inbox poll reads at most while it skips browser-only questions. */
10
+ const INBOX_PAGES = 5;
7
11
 
8
12
  export const createDeviceActions = (appName: (appId: string, locale: string) => string): AppDeviceActions => {
9
13
  const view: AppDeviceActions["view"] = async ({ userId, issuer, actionId }) => {
10
14
  const { items } = await listPending({ approverUserId: userId, id: actionId, limit: 1 });
11
- return items[0] ? appActionView(items[0], { issuer, appName }) : null;
15
+ const row = items[0];
16
+ if (!row) return null;
17
+ const policy = (await loadApprovalActionPolicies([row]))(row);
18
+ const mapped = await appActionView(row, { issuer, appName, policy });
19
+ return appActionOffered(mapped, pendingApprovalCard(row, appName, policy), policy) ? mapped : null;
12
20
  };
13
21
  return {
14
22
  inbox: async ({ userId, issuer }) => {
15
- const { items, count } = await listPending({ approverUserId: userId, limit: APP_ACTION_LIMITS.inboxActions });
16
- const views = await Promise.all(items.map((row) => appActionView(row, { issuer, appName })));
17
- return { actions: views.map(appActionSummary), moreActions: Math.max(0, count - items.length) };
23
+ // Browser-only questions are skipped, so read on past them. One poll reads at most this many pages of open
24
+ // requests, newest first, which keeps each 5-second poll bounded.
25
+ const actions: AppActionSummary[] = [];
26
+ let count = 0;
27
+ let cursor: string | undefined;
28
+ for (let page = 0; page < INBOX_PAGES && actions.length < APP_ACTION_LIMITS.inboxActions; page += 1) {
29
+ const result = await listPending({ approverUserId: userId, limit: APPROVALS_PAGE_SIZE, ...(cursor ? { cursor } : {}) });
30
+ count = result.count;
31
+ const policyOf = await loadApprovalActionPolicies(result.items);
32
+ for (const row of result.items) {
33
+ const policy = policyOf(row);
34
+ const mapped = await appActionView(row, { issuer, appName, policy });
35
+ if (appActionOffered(mapped, pendingApprovalCard(row, appName, policy), policy)) actions.push(appActionSummary(mapped));
36
+ if (actions.length === APP_ACTION_LIMITS.inboxActions) break;
37
+ }
38
+ if (!result.nextCursor) break;
39
+ cursor = result.nextCursor;
40
+ }
41
+ return { actions, moreActions: Math.max(0, count - actions.length) };
18
42
  },
19
43
  view,
20
44
  decide: async ({ userId, deviceId, issuer, actionId, digest, approved }) => {
@@ -54,6 +54,9 @@ export const AuthResponseSchema = z.object({
54
54
  user: UserSchema,
55
55
  });
56
56
 
57
+ /** Password-change sign-in result; retry ending other sign-ins when signOutOthersFailed is true. */
58
+ export const PasswordChangeAuthResponseSchema = AuthResponseSchema.extend({ signOutOthersFailed: z.boolean() });
59
+
57
60
  export const VerifyPasskeyAuthenticationSchema = z.object({
58
61
  response: z.unknown(),
59
62
  acceptedAgb: z.literal(true).optional(),
package/src/api/auth.ts CHANGED
@@ -9,6 +9,7 @@ import { accounts, audit, authFlows, getFreeIpaConfig, logger, webauthn } from "
9
9
  import { isAccountCategoryAllowed } from "../services/account-category-policy";
10
10
  import type { AuthNotificationSender } from "../services/auth-flows/notification-sender";
11
11
  import { legalConsent } from "../services/legal-consent";
12
+ import { SessionEpochChangedError } from "../services/session";
12
13
  import * as categorySettings from "../services/settings";
13
14
  import { publicCloudOrigin } from "../shared/app-url";
14
15
 
@@ -20,6 +21,7 @@ import {
20
21
  AuthResponseSchema,
21
22
  EmailLoginSchema,
22
23
  LoginSchema,
24
+ PasswordChangeAuthResponseSchema,
23
25
  PasswordResetCompleteSchema,
24
26
  PasswordResetRequestSchema,
25
27
  VerifyPasskeyAuthenticationSchema,
@@ -52,6 +54,7 @@ export const createAuthRoutes = (notificationSender: AuthNotificationSender) =>
52
54
  responses: {
53
55
  200: jsonResponse(AuthResponseSchema, "Login successful"),
54
56
  401: jsonResponse(ErrorResponseSchema, "Invalid username or password"),
57
+ 409: jsonResponse(ErrorResponseSchema, "Signed out everywhere while signing in; sign in again"),
55
58
  503: jsonResponse(ErrorResponseSchema, "FreeIPA unavailable"),
56
59
  },
57
60
  }),
@@ -76,10 +79,18 @@ export const createAuthRoutes = (notificationSender: AuthNotificationSender) =>
76
79
  }
77
80
 
78
81
  // Issue through the configured migration gate (legacy opaque or JWT family).
79
- const sessionToken = await auth.session.create(c, loginResult.userId, {
80
- method: "password",
81
- notify: notificationSender.sendNewBrowserSignIn,
82
- });
82
+ let sessionToken: string;
83
+ try {
84
+ sessionToken = await auth.session.create(c, loginResult.userId, {
85
+ method: "password",
86
+ notify: notificationSender.sendNewBrowserSignIn,
87
+ authEpoch: loginResult.authEpoch,
88
+ });
89
+ } catch (error) {
90
+ if (error instanceof SessionEpochChangedError)
91
+ return c.json({ message: "You were signed out everywhere while signing in. Sign in again." }, 409);
92
+ throw error;
93
+ }
83
94
 
84
95
  log.info("Login successful", { uid: loginResult.user.uid });
85
96
  return c.json({
@@ -153,10 +164,11 @@ export const createAuthRoutes = (notificationSender: AuthNotificationSender) =>
153
164
  tags: ["Auth"],
154
165
  summary: "Change expired password",
155
166
  description:
156
- "Change an expired or temporary password using FreeIPA's change_password endpoint. No active session required. For regular password changes of a logged-in user, use POST /api/me/password instead.",
167
+ "Change an expired or temporary password using FreeIPA's change_password endpoint. Ends the account's existing sign-ins before creating the new browser session. When signOutOthersFailed is true, the new browser session is issued and the client retries POST /api/me/sign-ins/sign-out-others. No active session required. For regular password changes of a logged-in user, use POST /api/me/password instead.",
157
168
  responses: {
158
- 200: jsonResponse(AuthResponseSchema, "Password changed and logged in"),
169
+ 200: jsonResponse(PasswordChangeAuthResponseSchema, "Password changed and logged in"),
159
170
  400: jsonResponse(ErrorResponseSchema, "Failed to change password"),
171
+ 409: jsonResponse(ErrorResponseSchema, "Password changed, but signed out everywhere while signing in; sign in again"),
160
172
  503: jsonResponse(ErrorResponseSchema, "FreeIPA unavailable"),
161
173
  },
162
174
  }),
@@ -183,13 +195,24 @@ export const createAuthRoutes = (notificationSender: AuthNotificationSender) =>
183
195
  return jsonError(c, changeResult.message, changeResult.status === 401 ? 401 : 400);
184
196
  }
185
197
 
186
- const sessionToken = await auth.session.create(c, changeResult.userId, {
187
- method: "expired_password",
188
- notify: notificationSender.sendNewBrowserSignIn,
189
- });
198
+ let sessionToken: string;
199
+ try {
200
+ sessionToken = await auth.session.create(c, changeResult.userId, {
201
+ method: "expired_password",
202
+ notify: notificationSender.sendNewBrowserSignIn,
203
+ authEpoch: changeResult.authEpoch,
204
+ });
205
+ } catch (error) {
206
+ if (error instanceof SessionEpochChangedError)
207
+ return c.json(
208
+ { message: "Your password was changed, but you were signed out everywhere while signing in. Sign in again." },
209
+ 409,
210
+ );
211
+ throw error;
212
+ }
190
213
 
191
214
  log.info("Password changed via expired flow", { uid: username });
192
- return c.json({ session_token: sessionToken, user: changeResult.user });
215
+ return c.json({ session_token: sessionToken, user: changeResult.user, signOutOthersFailed: changeResult.signOutOthersFailed });
193
216
  },
194
217
  )
195
218
  .post(
@@ -278,11 +301,13 @@ export const createAuthRoutes = (notificationSender: AuthNotificationSender) =>
278
301
  tags: ["Auth"],
279
302
  summary: "Complete password reset",
280
303
  description:
281
- "Set a new password using a one-time reset token. Creates a normal Cloud session only after the password was changed successfully.",
304
+ "Use a one-time reset token to first sign the account out everywhere, including unpairing Cloud Login and mobile app phones, then set the password, sign out everywhere again and create the new browser session. If signing out everywhere fails before the change, the password stays unchanged; if it fails after the change, no session is issued. Both answer 500, and a new reset link is required. signOutOthersFailed is false whenever a session is issued.",
282
305
  responses: {
283
- 200: jsonResponse(AuthResponseSchema, "Password reset completed and session created"),
306
+ 200: jsonResponse(PasswordChangeAuthResponseSchema, "Password reset completed and session created"),
284
307
  400: jsonResponse(ErrorResponseSchema, "Password reset failed"),
285
308
  401: jsonResponse(ErrorResponseSchema, "Invalid or expired reset token"),
309
+ 409: jsonResponse(ErrorResponseSchema, "Password changed, but signed out everywhere while signing in; sign in again"),
310
+ 500: jsonResponse(ErrorResponseSchema, "Password reset failed"),
286
311
  },
287
312
  }),
288
313
  v("json", PasswordResetCompleteSchema),
@@ -301,12 +326,23 @@ export const createAuthRoutes = (notificationSender: AuthNotificationSender) =>
301
326
  return jsonError(c, result.message, status);
302
327
  }
303
328
 
304
- const sessionToken = await auth.session.create(c, result.userId, {
305
- method: "password_reset",
306
- notify: notificationSender.sendNewBrowserSignIn,
307
- });
329
+ let sessionToken: string;
330
+ try {
331
+ sessionToken = await auth.session.create(c, result.userId, {
332
+ method: "password_reset",
333
+ notify: notificationSender.sendNewBrowserSignIn,
334
+ authEpoch: result.authEpoch,
335
+ });
336
+ } catch (error) {
337
+ if (error instanceof SessionEpochChangedError)
338
+ return c.json(
339
+ { message: "Your password was changed, but you were signed out everywhere while signing in. Sign in again." },
340
+ 409,
341
+ );
342
+ throw error;
343
+ }
308
344
  log.info("Password reset completed", { uid: result.user.uid });
309
- return c.json({ user: result.user, session_token: sessionToken });
345
+ return c.json({ user: result.user, session_token: sessionToken, signOutOthersFailed: result.signOutOthersFailed });
310
346
  },
311
347
  )
312
348
  .post(
package/src/api/mcp.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  import { Hono, type MiddlewareHandler } from "hono";
16
16
  import { z } from "zod";
17
17
  import { readBoundedJson } from "../_internal/bounded-json";
18
+ import { capabilitySentenceText } from "../_internal/capability-sentences";
18
19
  import {
19
20
  HELP_SEARCH_MAX_LIMIT,
20
21
  helpAgentMarkdown,
@@ -31,6 +32,7 @@ import {
31
32
  CAPABILITY_MAX_RESULT_BYTES,
32
33
  CAPABILITY_PROTOCOL_VERSION,
33
34
  type CapabilityActionManifest,
35
+ CapabilityActionReceiptSchema,
34
36
  type CapabilityQueryManifest,
35
37
  CapabilitySemanticLinkSchema,
36
38
  type CloudResourceRef,
@@ -42,12 +44,14 @@ import {
42
44
  } from "../contracts/capabilities";
43
45
  import type { AppRegistryEntry, CapabilityRegistryEntry } from "../contracts/registry";
44
46
  import { type AuthContext, auth, type RequestAuthority, rateLimit, rejectReservedWorkloadCredential, resolveLocale } from "../server";
47
+ import { preferredLocale } from "../server/locale";
45
48
  import { noTransform } from "../server/middleware/no-transform";
46
49
  import { createHelpReader, type HelpArticle, type HelpMetadata, type HelpReader, type HelpReaderFactory } from "../services/help";
47
50
  import { normalizeInvocationRequestId } from "../services/identity/invocation-token";
48
51
  import { logger } from "../services/logging";
49
52
  import { get } from "../services/settings";
50
53
  import { cloudMcpResourceUri, publicCloudOrigin } from "../shared/app-url";
54
+ import { normalizeTimeZone } from "../shared/time";
51
55
  import { type CapabilityDispatchDependencies, dispatchCapability } from "./capabilities";
52
56
 
53
57
  const MCP_TOOL_PAGE_SIZE = 100;
@@ -67,6 +71,7 @@ type McpRouteDependencies = CapabilityDispatchDependencies & {
67
71
  listApps?: () => Promise<AppRegistryEntry[]>;
68
72
  help?: HelpReaderFactory;
69
73
  getOperatorLocale?: () => Promise<string | undefined>;
74
+ getOperatorTimeZone?: () => Promise<string | undefined>;
70
75
  getAppUrl?: () => Promise<string>;
71
76
  authenticate?: MiddlewareHandler<AuthContext>;
72
77
  limit?: MiddlewareHandler<AuthContext>;
@@ -682,14 +687,33 @@ const createMcpServer = (
682
687
  origin,
683
688
  );
684
689
  }
685
- const resultFor = (value: Record<string, unknown>): CallToolResult => {
690
+ const receipt = response.ok && selected.kind === "actions" ? CapabilityActionReceiptSchema.safeParse(body.receipt) : null;
691
+ let receiptText: string | undefined;
692
+ if (receipt?.success) {
693
+ // The receipt line is extra presentation: if Cloud cannot word it, the completed Action's result still goes out.
694
+ try {
695
+ const locale = preferredLocale(request.headers) ?? (await resolveHelpLocale());
696
+ const timeZone = normalizeTimeZone(
697
+ await (dependencies.getOperatorTimeZone ?? (() => get<string>("app.timezone")))().catch(() => "UTC"),
698
+ );
699
+ receiptText = capabilitySentenceText(receipt.data.text, receipt.data.slots ?? {}, { locale, timeZone });
700
+ } catch (error) {
701
+ log.warn("MCP receipt text unavailable", { error: error instanceof Error ? error.message : String(error) });
702
+ }
703
+ }
704
+ const resultFor = (value: Record<string, unknown>, withReceipt = true): CallToolResult => {
686
705
  const result = toolResult(value, !response.ok);
687
706
  if (origin) result.content.push(...semanticResourceLinks(value, origin));
707
+ if (withReceipt && receiptText) result.content.unshift({ type: "text", text: receiptText });
688
708
  return result;
689
709
  };
690
- // Added hrefs are optional: they never turn a result that fits into RESPONSE_TOO_LARGE.
710
+ // Both enrichments are optional: neither turns a result that fits into RESPONSE_TOO_LARGE.
691
711
  const result = resultFor(body);
692
- return ensureBoundedToolResult(fitsToolResult(result) || body === providerBody ? result : resultFor(providerBody));
712
+ if (fitsToolResult(result)) return result;
713
+ const plain = resultFor(body, false);
714
+ if (fitsToolResult(plain)) return plain;
715
+ const original = resultFor(providerBody);
716
+ return ensureBoundedToolResult(fitsToolResult(original) ? original : resultFor(providerBody, false));
693
717
  });
694
718
 
695
719
  return server;
package/src/api/me.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import { err, fail, ok, type Result } from "@k2b/stdlib";
8
8
  import { sql } from "bun";
9
9
  import { type Context, Hono, type MiddlewareHandler } from "hono";
10
+ import { deleteCookie } from "hono/cookie";
10
11
  import { createMiddleware } from "hono/factory";
11
12
  import { describeRoute } from "hono-openapi";
12
13
  import { z } from "zod";
@@ -27,6 +28,7 @@ import {
27
28
  NotificationQuietSettingsSchema,
28
29
  RegisterBrowserNotificationEndpointSchema,
29
30
  ServiceAccountCredentialSchema,
31
+ SignOutOthersResponseSchema,
30
32
  UpdateAvatarResponseSchema,
31
33
  UpdateAvatarSchema,
32
34
  UpdateNotificationQuietSettingsSchema,
@@ -47,6 +49,7 @@ import {
47
49
  accountsAppService as accountsService,
48
50
  audit,
49
51
  browserNotifications,
52
+ logger,
50
53
  mandates,
51
54
  notifications,
52
55
  serviceAccountCredentials,
@@ -61,6 +64,8 @@ import { createMePanelConsentRoutes } from "./me-panel-consent";
61
64
  import { createMeRailRoutes } from "./me-rail";
62
65
  import { createMeWebVitalsRoutes } from "./me-web-vitals";
63
66
 
67
+ const log = logger("api:me");
68
+
64
69
  const toAccountsActor = (user: AuthContext["Variables"]["user"]) => ({
65
70
  userId: user.id,
66
71
  uid: user.uid,
@@ -747,25 +752,96 @@ const app = new Hono<AuthContext>()
747
752
  describeRoute({
748
753
  tags: ["Me"],
749
754
  summary: "Change current user password",
750
- description: "Change the authenticated user's password. Requires the current password for verification.",
755
+ description:
756
+ "Change the authenticated user's password and end every other sign-in. Requires the current password. The current browser stays signed in with a new cookie and its original sign-in time. signInAgain is true when the caller must sign in again: this browser could not be kept, or the request did not come from a browser sign-in.",
751
757
  ...requiresAuth,
752
758
  responses: {
753
- 200: jsonResponse(MessageResponseSchema, "Password changed"),
759
+ 200: jsonResponse(SignOutOthersResponseSchema, "Password changed"),
754
760
  400: jsonResponse(ErrorResponseSchema, "Failed to change password"),
755
761
  401: jsonResponse(ErrorResponseSchema, "Current password is incorrect"),
762
+ 500: jsonResponse(
763
+ ErrorResponseSchema,
764
+ "Password changed, but signing out other sign-ins failed (SIGN_OUT_OTHERS_FAILED). Retry POST /api/me/sign-ins/sign-out-others.",
765
+ ),
756
766
  403: jsonResponse(ErrorResponseSchema, "Not permitted for API keys or other credentials"),
757
767
  },
758
768
  }),
759
769
  v("json", ChangePasswordSchema),
760
770
  async (c) =>
761
771
  respond(c, async () => {
772
+ const keep = await auth.browserSignIn(c);
762
773
  const result = await accountsService.user.changeOwnPassword({
774
+ keep: keep && { sid: keep.sid, authEpoch: keep.authEpoch },
763
775
  user: c.get("user"),
764
776
  currentPassword: c.req.valid("json").currentPassword,
765
777
  newPassword: c.req.valid("json").newPassword,
766
778
  });
767
779
  if (!result.ok) return result;
768
- return ok({ message: "Password changed successfully." });
780
+ if (result.data.kept) auth.session.setWebCookie(c, result.data.kept.token, result.data.kept.expiresAt);
781
+ const signInAgain = !result.data.kept;
782
+ if (keep && signInAgain) deleteCookie(c, "session_token", { path: "/" });
783
+ return ok({
784
+ message: signInAgain
785
+ ? keep
786
+ ? "Password changed. You are signed out everywhere, including this browser. Sign in again."
787
+ : "Password changed. You are signed out everywhere. Sign in again."
788
+ : "Password changed successfully.",
789
+ signInAgain,
790
+ });
791
+ }),
792
+ )
793
+
794
+ .post(
795
+ "/sign-ins/sign-out-others",
796
+ auth.rejectAppSession,
797
+ requireBrowserSignIn,
798
+ describeRoute({
799
+ tags: ["Me"],
800
+ summary: "Sign out every other sign-in",
801
+ description:
802
+ "End other sign-ins and rotate this browser's cookie while preserving its sign-in time and expiry; signInAgain is true if this browser could not be kept. Cloud Login phones stay paired. API keys, passkeys and mandates stay valid.",
803
+ ...requiresAuth,
804
+ responses: {
805
+ 200: jsonResponse(SignOutOthersResponseSchema, "Other sign-ins ended"),
806
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
807
+ 403: jsonResponse(
808
+ ErrorResponseSchema,
809
+ "Browser sign-in required (BROWSER_SIGN_IN_REQUIRED); mobile app sessions receive FORBIDDEN",
810
+ ),
811
+ 500: jsonResponse(ErrorResponseSchema, "Signing out other sign-ins failed (SIGN_OUT_OTHERS_FAILED)"),
812
+ },
813
+ }),
814
+ async (c) =>
815
+ respond(c, async () => {
816
+ const signIn = await auth.browserSignIn(c);
817
+ if (!signIn) throw new Error("Browser sign-in gate was not applied");
818
+ try {
819
+ const { kept } = await auth.session.endOtherSignIns(c.get("user").id, {
820
+ keep: { sid: signIn.sid, authEpoch: signIn.authEpoch },
821
+ reason: "sign_out_others",
822
+ });
823
+ const user = c.get("user");
824
+ try {
825
+ await audit.record({
826
+ action: "auth.session.sign_out_others",
827
+ outcome: "allowed",
828
+ actor: toAccountsActor(user),
829
+ target: { type: "user", id: user.id, label: user.uid, provider: user.provider },
830
+ metadata: { keptThisBrowser: !!kept },
831
+ });
832
+ } catch {
833
+ log.warn("Other sign-ins ended, but recording the audit event failed", { userId: user.id });
834
+ }
835
+ if (kept) auth.session.setWebCookie(c, kept.token, kept.expiresAt);
836
+ const signInAgain = !kept;
837
+ if (signInAgain) deleteCookie(c, "session_token", { path: "/" });
838
+ return ok({
839
+ message: signInAgain ? "Other sign-ins ended. Sign in again in this browser." : "Other sign-ins ended.",
840
+ signInAgain,
841
+ });
842
+ } catch {
843
+ return fail({ code: "SIGN_OUT_OTHERS_FAILED", status: 500, message: "Signing out your other sign-ins did not work. Try again." });
844
+ }
769
845
  }),
770
846
  )
771
847
 
@@ -1,12 +1,9 @@
1
1
  import { dates } from "@k2b/stdlib";
2
2
  import { Button, dialogCore, PanelDialog, Placeholder, panelDialogOptions, prompts, useLocale } from "@k2b/ui";
3
3
  import { createMemo, createSignal, For, type JSX, onCleanup, onMount, Show } from "solid-js";
4
- import { capabilityQuestionText, capabilityUnknownText } from "../_internal/capability-sentences";
5
- import { isLegacyActionReview, storedActionReview } from "../ai/action-review";
6
- import { type ActionCard, legacyActionCard, reviewActionCard } from "../ai/chat/action-card";
4
+ import type { ActionCard } from "../ai/chat/action-card";
7
5
  import { actionMessages } from "../ai/chat/action-messages";
8
6
  import { ActionQuestion, ActionReceipt } from "../ai/chat/action-question";
9
- import { aiChatMessages } from "../ai/chat/messages";
10
7
  import { refreshAppBadges } from "../browser/app-badges";
11
8
  import { coreClient } from "../clients/core";
12
9
  import {
@@ -17,6 +14,7 @@ import {
17
14
  ApprovalPageSchema,
18
15
  type PendingApproval,
19
16
  } from "../contracts/approvals";
17
+ import { approvalCard } from "./approval-card";
20
18
  import { approvalMessages } from "./messages";
21
19
 
22
20
  /** How a decided request ends in this list. It stays in place as a receipt until the next load. */
@@ -57,60 +55,13 @@ const useMessages = () => {
57
55
  return () => approvalMessages.resolve([locale()]).t;
58
56
  };
59
57
 
60
- const lines = (item: PendingApproval) =>
61
- item.sentence
62
- .split("\n")
63
- .map((line) => line.trim())
64
- .filter(Boolean);
65
-
66
- /** The first line names the app and the Action; the app already shows as the row's icon and origin. */
67
- const approvalTitle = (item: PendingApproval): string => {
68
- const first = lines(item)[0] ?? item.action;
69
- const prefix = `${item.app.name}: `;
70
- return first.startsWith(prefix) && first.length > prefix.length ? first.slice(prefix.length) : first;
71
- };
72
-
73
- /** Without a review, the sentence's later `Label: value` lines are what the person approves. */
74
- const sentenceRows = (item: PendingApproval) =>
75
- lines(item)
76
- .slice(1)
77
- .map((line) => {
78
- const match = /^([^:]{1,40}):\s+(.+)$/.exec(line);
79
- return match ? { label: match[1]!, value: match[2] ?? "" } : { label: "", value: line };
80
- });
81
-
82
- /**
83
- * The same card as the chat: a new review gives the app's sentence, reason, and preview; an old review its literal
84
- * message and rows; a request without a review its sentence. Times show in the reader's zone (UTC until the page
85
- * passes one, the same on the server and in the first browser render).
86
- */
87
- const approvalCard = (item: PendingApproval, locale: string, timeZone: string): ActionCard => {
88
- const app = { name: item.app.name, icon: item.app.icon ?? "ti ti-shield-check" };
89
- const context = { locale, timeZone, app };
90
- const review = item.review ? storedActionReview(item.review) : null;
91
- if (review && !isLegacyActionReview(review)) return reviewActionCard(review, context);
92
- const title = approvalTitle(item);
93
- const t = aiChatMessages(locale);
94
- return legacyActionCard(
95
- {
96
- question: review?.legacy.question ?? capabilityQuestionText(title, locale),
97
- rows: review?.legacy.rows ?? sentenceRows(item),
98
- ...(review?.legacy.markdown ? { markdown: review.legacy.markdown } : {}),
99
- ...(review?.legacy.links ? { links: review.legacy.links } : {}),
100
- subject: title,
101
- title,
102
- outcome: {
103
- running: t.receiptRunning({ title }),
104
- done: t.receiptDone({ title }),
105
- rejected: t.receiptRejected({ title }),
106
- notRun: t.receiptNotRun({ title, stopped: false }),
107
- unknown: capabilityUnknownText(title, locale),
108
- unknownStill: capabilityUnknownText(title, locale, true),
109
- },
110
- },
111
- context,
112
- );
113
- };
58
+ const cardInput = (item: PendingApproval) => ({
59
+ sentence: item.sentence,
60
+ action: item.action,
61
+ appName: item.app.name,
62
+ appIcon: item.app.icon,
63
+ review: item.review,
64
+ });
114
65
 
115
66
  /** Origin of the request: owning app, the chat it waits in, when Cloud asked, and when it expires. */
116
67
  function Origin(props: { item: PendingApproval; withApp?: boolean }) {
@@ -170,7 +121,7 @@ function ApprovalCard(props: {
170
121
  }) {
171
122
  const t = useMessages();
172
123
  const locale = useLocale();
173
- const card = createMemo(() => approvalCard(props.item, locale(), READER_TIME_ZONE));
124
+ const card = createMemo(() => approvalCard(cardInput(props.item), locale(), READER_TIME_ZONE));
174
125
  const current = () => props.outcome ?? (props.failed ? "failed" : "sending");
175
126
  const state = () => (props.outcome || props.busy || props.failed ? "decided" : "open");
176
127
  // `confirm` asks every time, whatever the stored options say.
@@ -251,7 +202,7 @@ const openDetails = (item: PendingApproval, locale: string) =>
251
202
  dialogCore.open<ApprovalDecisionInput>(
252
203
  (close) => {
253
204
  const t = () => approvalMessages.resolve([locale]).t;
254
- const card = approvalCard(item, locale, READER_TIME_ZONE);
205
+ const card = approvalCard(cardInput(item), locale, READER_TIME_ZONE);
255
206
  return (
256
207
  <PanelDialog>
257
208
  <PanelDialog.Header
@@ -290,7 +241,13 @@ const openDetails = (item: PendingApproval, locale: string) =>
290
241
  </PanelDialog>
291
242
  );
292
243
  },
293
- { ...panelDialogOptions, history: true, ariaLabel: approvalMessages.resolve([locale]).t.approvalFor({ title: approvalTitle(item) }) },
244
+ {
245
+ ...panelDialogOptions,
246
+ history: true,
247
+ ariaLabel: approvalMessages
248
+ .resolve([locale])
249
+ .t.approvalFor({ title: approvalCard(cardInput(item), locale, READER_TIME_ZONE).questionText }),
250
+ },
294
251
  );
295
252
 
296
253
  function Row(props: {
@@ -304,6 +261,8 @@ function Row(props: {
304
261
  }) {
305
262
  const t = useMessages();
306
263
  const locale = useLocale();
264
+ // The band words the request as the card does: the plain question and Cloud's reason line.
265
+ const card = createMemo(() => approvalCard(cardInput(props.item), locale(), READER_TIME_ZONE));
307
266
  const details = async () => {
308
267
  const input = await openDetails(props.item, locale());
309
268
  if (input) props.decide(input);
@@ -322,10 +281,13 @@ function Row(props: {
322
281
  />
323
282
  }
324
283
  >
325
- <article class="cloud-approval-band" data-decided={props.outcome} aria-label={t().approvalFor({ title: approvalTitle(props.item) })}>
284
+ <article class="cloud-approval-band" data-decided={props.outcome} aria-label={t().approvalFor({ title: card().questionText })}>
326
285
  <i class={props.outcome ? outcomeIcon(props.outcome, true) : "ti ti-alert-triangle"} aria-hidden="true" />
327
286
  <div class="cloud-approval-band__text">
328
- <strong>{approvalTitle(props.item)}</strong>
287
+ <strong lang={card().locale}>{card().questionText}</strong>
288
+ <span class="cloud-approval-band__reason" lang={card().locale}>
289
+ {card().reason.text}
290
+ </span>
329
291
  <Origin item={props.item} withApp={false} />
330
292
  </div>
331
293
  <div class="cloud-approval__foot">