@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
@@ -0,0 +1,68 @@
1
+ import { capabilityQuestionText, capabilityUnknownText } from "../_internal/capability-sentences";
2
+ import { type AiStoredActionReview, isLegacyActionReview, storedActionReview } from "../ai/action-review";
3
+ import { type ActionCard, legacyActionCard, reviewActionCard } from "../ai/chat/action-card";
4
+ import { aiChatMessages } from "../ai/chat/messages";
5
+ import type { CapabilityActionReviewData } from "../contracts/capabilities";
6
+
7
+ export type ApprovalCardInput = {
8
+ sentence: string;
9
+ action: string;
10
+ appName: string;
11
+ appIcon?: string | null;
12
+ review: CapabilityActionReviewData | AiStoredActionReview | null;
13
+ };
14
+
15
+ const lines = (item: ApprovalCardInput) =>
16
+ item.sentence
17
+ .split("\n")
18
+ .map((line) => line.trim())
19
+ .filter(Boolean);
20
+
21
+ /** The first line names the app and the Action; the app already shows as the row's icon and origin. */
22
+ const approvalTitle = (item: ApprovalCardInput): string => {
23
+ const first = lines(item)[0] ?? item.action;
24
+ const prefix = `${item.appName}: `;
25
+ return first.startsWith(prefix) && first.length > prefix.length ? first.slice(prefix.length) : first;
26
+ };
27
+
28
+ /** Without a review, the sentence's later `Label: value` lines are what the person approves. */
29
+ const sentenceRows = (item: ApprovalCardInput) =>
30
+ lines(item)
31
+ .slice(1)
32
+ .map((line) => {
33
+ const match = /^([^:]{1,40}):\s+(\S.*)$/.exec(line);
34
+ return match ? { label: match[1]!, value: match[2] ?? "" } : { label: "", value: line };
35
+ });
36
+
37
+ /**
38
+ * The same card as the chat: a new review gives the app's sentence, reason, and preview; an old review its literal
39
+ * message and rows; a request without a review its sentence. Times show in the reader's zone (UTC until the page
40
+ * passes one, the same on the server and in the first browser render).
41
+ */
42
+ export const approvalCard = (item: ApprovalCardInput, locale: string, timeZone: string): ActionCard => {
43
+ const app = { name: item.appName, icon: item.appIcon ?? "ti ti-shield-check" };
44
+ const context = { locale, timeZone, app };
45
+ const review = item.review ? ("legacy" in item.review ? item.review : storedActionReview(item.review)) : null;
46
+ if (review && !isLegacyActionReview(review)) return reviewActionCard(review, context);
47
+ const title = approvalTitle(item);
48
+ const t = aiChatMessages(locale);
49
+ return legacyActionCard(
50
+ {
51
+ question: review?.legacy.question ?? capabilityQuestionText(title, locale),
52
+ rows: review?.legacy.rows ?? sentenceRows(item),
53
+ ...(review?.legacy.markdown ? { markdown: review.legacy.markdown } : {}),
54
+ ...(review?.legacy.links ? { links: review.legacy.links } : {}),
55
+ subject: title,
56
+ title,
57
+ outcome: {
58
+ running: t.receiptRunning({ title }),
59
+ done: t.receiptDone({ title }),
60
+ rejected: t.receiptRejected({ title }),
61
+ notRun: t.receiptNotRun({ title, stopped: false }),
62
+ unknown: capabilityUnknownText(title, locale),
63
+ unknownStill: capabilityUnknownText(title, locale, true),
64
+ },
65
+ },
66
+ context,
67
+ );
68
+ };
@@ -360,14 +360,14 @@ const actionFields = {
360
360
  createdAt: DateTime,
361
361
  expiresAt: DateTime,
362
362
  };
363
- /** One inbox row. `sentence` is the start of the app's sentence; read the Action for everything the person approves. */
363
+ /** One inbox row. `sentence` is the start of the plain question; read the Action for everything the person approves. */
364
364
  export const AppActionSummarySchema = z.object({ ...actionFields, sentence: PlainText(APP_ACTION_LIMITS.summaryChars).min(1) }).strict();
365
365
  export const AppActionViewSchema = z
366
366
  .object({
367
367
  ...actionFields,
368
- /** What the Action does, in the app's words and the person's language. */
368
+ /** The plain Assistant question, in the originating turn's language. */
369
369
  sentence: PlainText(APP_ACTION_LIMITS.sentenceChars).min(1),
370
- /** The app's review of this call, or `null`. */
370
+ /** The question's reason line, or `null` for older snapshots. */
371
371
  message: PlainText(APP_ACTION_LIMITS.messageChars).nullable(),
372
372
  details: z
373
373
  .array(
@@ -195,7 +195,7 @@ const reviewBlock = (max: number) =>
195
195
  .max(max)
196
196
  .refine((text) => !/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(text.replace(/[\n\t]/g, "")), "Contains unsafe characters");
197
197
  export const CapabilityPersonSchema = z
198
- .object({ name: reviewLine(120).optional(), address: reviewLine(320).optional(), userId: z.uuid().optional() })
198
+ .object({ name: reviewLine(200).optional(), address: reviewLine(320).optional(), userId: z.uuid().optional() })
199
199
  .strict()
200
200
  .refine((person) => person.name !== undefined || person.address !== undefined, "A person needs a name or address");
201
201
  export type CapabilityPerson = z.infer<typeof CapabilityPersonSchema>;
@@ -206,6 +206,9 @@ export const MessageResponseSchema = z.object({
206
206
  });
207
207
  export type MessageResponse = z.infer<typeof MessageResponseSchema>;
208
208
 
209
+ export const SignOutOthersResponseSchema = MessageResponseSchema.extend({ signInAgain: z.boolean() });
210
+ export type SignOutOthersResponse = z.infer<typeof SignOutOthersResponseSchema>;
211
+
209
212
  export type MutationResult<T = void> = { ok: true; data: T } | { ok: false; error: string; status: 400 | 401 | 403 | 404 | 409 | 500 };
210
213
 
211
214
  export const PermissionLevelSchema = z.enum(["none", "read", "write", "admin"]);
@@ -414,12 +414,14 @@ const getAuthority = (c: Context<AuthContext>): RequestAuthority => {
414
414
  const isAppSession = <E extends AuthContext>(c: Context<E>): boolean => isAppSessionActor(c.get("actor"));
415
415
 
416
416
  /** Returns only the browser session identity authenticated for this request. */
417
- const browserSignIn = async <E extends AuthContext>(c: Context<E>): Promise<{ userId: string; sid: string } | null> => {
417
+ const browserSignIn = async <E extends AuthContext>(c: Context<E>): Promise<{ userId: string; sid: string; authEpoch: number } | null> => {
418
418
  if (c.get("credentialKind") !== "session" || c.get("actor")?.kind !== "user" || isAppSession(c)) return null;
419
419
  const token = c.get("sessionToken");
420
420
  if (!token) return null;
421
421
  const authenticated = await session.authenticateRequest(c, token);
422
- return authenticated?.data.kind === "web" ? { userId: authenticated.data.userId, sid: authenticated.data.sid } : null;
422
+ return authenticated?.data.kind === "web"
423
+ ? { userId: authenticated.data.userId, sid: authenticated.data.sid, authEpoch: authenticated.data.authEpoch }
424
+ : null;
423
425
  };
424
426
 
425
427
  /** The standard answer for actions the mobile app may not take. */
@@ -31,6 +31,7 @@ import { getFreeIpaConfig } from "../freeipa-config";
31
31
  import { type LogEntry, logger, logging } from "../logging";
32
32
  import { isUniqueViolation } from "../postgres";
33
33
  import { providers } from "../providers";
34
+ import { session } from "../session";
34
35
  import { get as getSetting } from "../settings";
35
36
  import { type AccountsActor, canMutateManagedGroup, hasOnlySelfUpdateFields, isAdminActor, isSelfTarget } from "./authz";
36
37
  import { listDuplicateEmails } from "./duplicate-emails";
@@ -838,7 +839,12 @@ export const accountsAppService = {
838
839
  * admin app is UI only and must not dispatch on provider or speak to
839
840
  * FreeIPA directly.
840
841
  */
841
- changeOwnPassword: async (config: { user: User; currentPassword: string; newPassword: string }): Promise<Result<void>> => {
842
+ changeOwnPassword: async (config: {
843
+ user: User;
844
+ currentPassword: string;
845
+ newPassword: string;
846
+ keep?: { sid: string; authEpoch: number } | null;
847
+ }): Promise<Result<{ kept: { token: string; expiresAt: Date } | null }>> => {
842
848
  const actor = { userId: config.user.id, uid: config.user.uid, roles: config.user.roles, provider: config.user.provider };
843
849
  if (!(await getFreeIpaConfig()).enabled) {
844
850
  const result = fail(err.badInput("FreeIPA is disabled."));
@@ -875,8 +881,22 @@ export const accountsAppService = {
875
881
  uid: config.user.uid,
876
882
  newPassword: config.newPassword,
877
883
  });
878
- const serviceResult = result.ok ? ok(undefined) : fail(toServiceError(result.status, result.error));
879
- return recordCompletedMutation({
884
+ let serviceResult: Result<{ kept: { token: string; expiresAt: Date } | null }>;
885
+ if (!result.ok) {
886
+ serviceResult = fail(toServiceError(result.status, result.error));
887
+ } else {
888
+ try {
889
+ serviceResult = ok(await session.endOtherSignIns(config.user.id, { keep: config.keep, reason: "password_change" }));
890
+ } catch {
891
+ serviceResult = fail({
892
+ code: "SIGN_OUT_OTHERS_FAILED",
893
+ status: 500,
894
+ message: "Password changed. Signing out your other sign-ins did not work.",
895
+ });
896
+ }
897
+ }
898
+ const record = result.ok ? audit.recordResultAfterSideEffect : audit.recordResult;
899
+ return record({
880
900
  action: "accounts.user.change_own_password",
881
901
  actor: auditActor(actor),
882
902
  target: userTarget(config.user),
@@ -675,10 +675,21 @@ export const resetPassword = async (params: { id: string }): Promise<MutationRes
675
675
  const serviceSession = await getServiceIpaSession();
676
676
  if (!serviceSession.ok) return serviceSession;
677
677
 
678
- return providers.ipa.users.resetPassword({
678
+ const result = await providers.ipa.users.resetPassword({
679
679
  ipaSession: serviceSession.data,
680
680
  id: params.id,
681
681
  });
682
+ if (!result.ok) return result;
683
+ try {
684
+ await session.endOtherSignIns(params.id, { reason: "password_reset" });
685
+ } catch {
686
+ return {
687
+ ok: false,
688
+ error: "The password was reset, but signing out the person's other sign-ins did not work. Reset the password again.",
689
+ status: 500,
690
+ };
691
+ }
692
+ return result;
682
693
  };
683
694
 
684
695
  export const switchProvider = async (params: { id: string; provider: UserProvider }): Promise<MutationResult<void>> => {
@@ -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
+ };
@@ -9,6 +9,7 @@ import { toPgTextArray } from "../postgres";
9
9
 
10
10
  export { countPending, listPending, presentPendingApproval } from "./list";
11
11
  export { migrateApprovals } from "./migrate";
12
+ export { type ApprovalActionPolicy, loadApprovalActionPolicies, pendingApprovalCard } from "./question";
12
13
 
13
14
  // Derived from chat's AI_ACTION_BUDGET_MS and Studio's one-day abandonment of pending calls.
14
15
  export const APPROVAL_MAX_WAIT_MS = 24 * 60 * 60_000;
@@ -33,7 +33,7 @@ export type PendingApprovalRow = {
33
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
36
+ export const openRequests = (approverUserId: string, id?: string) => sql`SELECT r.* FROM approvals.requests r
37
37
  WHERE r.approver_user_id=${approverUserId}::uuid AND r.status='pending' AND r.expires_at > now()
38
38
  AND r.approver_auth_epoch=(SELECT auth_epoch FROM auth.users WHERE id=${approverUserId}::uuid)
39
39
  AND (${id ?? null}::uuid IS NULL OR r.id=${id ?? null}::uuid)
@@ -0,0 +1,68 @@
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
+ CapabilityActionCallReviewSchema,
7
+ type CapabilityActionPolicy,
8
+ CapabilityActionReviewDataSchema,
9
+ capabilityActionPolicy,
10
+ } from "../../contracts/capabilities";
11
+ import { DEFAULT_LOCALE } from "../../server/locale";
12
+ import type { PendingApprovalRow } from "./list";
13
+
14
+ /**
15
+ * One open approval as the question card words it, for places outside the browser: Cloud Login and notifications.
16
+ * The approvals page builds the same card from the same rows.
17
+ */
18
+
19
+ /**
20
+ * The declared policy of a row's Action, needed for new-shape reviews' device eligibility and fallback reason:
21
+ * `undefined` when none is needed or declared, `null` when the registry could not say.
22
+ */
23
+ export type ApprovalActionPolicy = CapabilityActionPolicy | null | undefined;
24
+ const needsPolicy = (row: PendingApprovalRow): boolean => {
25
+ if (row.storedReview && "sentence" in row.storedReview) return true;
26
+ const review = CapabilityActionCallReviewSchema.safeParse(row.details);
27
+ return review.success;
28
+ };
29
+ /** Reads the registry once for the rows that need their Action's declared policy, as the approvals page does. */
30
+ export const loadApprovalActionPolicies = async (
31
+ rows: readonly PendingApprovalRow[],
32
+ ): Promise<(row: PendingApprovalRow) => ApprovalActionPolicy> => {
33
+ if (!rows.some(needsPolicy)) return () => undefined;
34
+ const capabilities = await listCapabilities().catch(() => null);
35
+ return (row) => {
36
+ if (!needsPolicy(row)) return undefined;
37
+ if (capabilities === null) return null;
38
+ // An app that is not registered right now cannot say why its Action asks either.
39
+ const manifest = capabilities.find((entry) => entry.appId === row.appId)?.manifest;
40
+ return manifest ? capabilityActionPolicy(manifest, row.action) : null;
41
+ };
42
+ };
43
+
44
+ /** Prefer the turn's snapshot, then the linked request's review. */
45
+ export const pendingApprovalReview = (
46
+ row: Pick<PendingApprovalRow, "storedReview" | "details">,
47
+ policy?: ApprovalActionPolicy,
48
+ ): AiStoredActionReview | null => {
49
+ const parsed = CapabilityActionReviewDataSchema.safeParse(row.details);
50
+ return row.storedReview ?? (parsed.success ? storedActionReview(parsed.data, policy ?? undefined) : null);
51
+ };
52
+
53
+ /** Resolve the recorded question in the language and time zone of its originating turn. */
54
+ export const pendingApprovalCard = (
55
+ row: PendingApprovalRow,
56
+ appName: (appId: string, locale: string) => string,
57
+ policy?: ApprovalActionPolicy,
58
+ fallbackLocale = DEFAULT_LOCALE,
59
+ ): ActionCard => {
60
+ const locale = row.runConfig?.kind === "compact" ? fallbackLocale : (row.runConfig?.locale ?? fallbackLocale);
61
+ const timeZone = row.runConfig?.kind === "compact" ? "UTC" : (row.runConfig?.timeZone ?? "UTC");
62
+ const review = pendingApprovalReview(row, policy);
63
+ return approvalCard(
64
+ { sentence: row.sentence, action: row.action, appName: appName(row.appId, locale), appIcon: null, review },
65
+ locale,
66
+ timeZone,
67
+ );
68
+ };
@@ -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
  };
@@ -29,6 +29,8 @@ type ResetAttemptSuccess = {
29
29
  ok: true;
30
30
  userId: string;
31
31
  user: User;
32
+ authEpoch: number;
33
+ signOutOthersFailed: boolean;
32
34
  };
33
35
 
34
36
  type ResetAttemptFailure =
@@ -154,13 +156,24 @@ const changeTemporaryPassword = async (params: {
154
156
  };
155
157
  }
156
158
 
157
- await session.revokeAllForUser(changeResult.userId);
159
+ // The old password stayed valid while FreeIPA changed it, so a sign-in or device paired in that time outlives the
160
+ // sign-out before the change. Signing out everywhere again ends them, and everything the change already ended.
161
+ // Without it, this browser gets no session: the retry for other sign-ins keeps devices, a new reset link does not.
162
+ // Bind issuance to this final sign-out so a later sign-out also ends this sign-in.
163
+ let authEpoch: number;
164
+ try {
165
+ authEpoch = await session.revokeAllForUser(changeResult.userId);
166
+ } catch {
167
+ log.warn("Password reset: signing out everywhere after the change failed", { userId: changeResult.userId });
168
+ return {
169
+ ok: false,
170
+ status: 500,
171
+ reason: "reset_failed",
172
+ message: "Your password was changed, but signing you out everywhere did not work. Request a new reset link to finish.",
173
+ };
174
+ }
158
175
 
159
- return {
160
- ok: true,
161
- userId: changeResult.userId,
162
- user: changeResult.user,
163
- };
176
+ return { ok: true, userId: changeResult.userId, user: changeResult.user, authEpoch, signOutOthersFailed: false };
164
177
  };
165
178
 
166
179
  type ResetRequest = { email: string; redirectTo?: string; locale?: string };
@@ -258,6 +271,19 @@ export const complete = async (params: { token?: string; newPassword: string }):
258
271
  };
259
272
  }
260
273
 
274
+ // The consumed reset token authorizes signing out everywhere; unpair phones before changing the password.
275
+ try {
276
+ await session.revokeAllForUser(target.userId);
277
+ } catch {
278
+ log.warn("Password reset stopped: signing out everywhere failed", { userId: target.userId });
279
+ return {
280
+ ok: false,
281
+ status: 500,
282
+ reason: "reset_failed",
283
+ message: "Signing you out everywhere did not work, so your password was not changed. Request a new reset link.",
284
+ };
285
+ }
286
+
261
287
  const resetResult = await providers.ipa.users.resetPassword({
262
288
  ipaSession: serviceSession.data,
263
289
  id: target.userId,
@@ -257,3 +257,5 @@ export {
257
257
  type NewBrowserSignInNotifier,
258
258
  } from "./known-browsers";
259
259
  export { LASTING_ACCESS_SIGN_IN_SECONDS, requireConfirmedBrowserSignIn } from "./session/recent";
260
+
261
+ export { deviceOffersApproval } from "./approvals/device";