@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.
- package/package.json +6 -3
- package/src/_internal/capability-sentences.ts +2 -1
- package/src/_internal/define-app.ts +13 -3
- package/src/_internal/heartbeat.ts +203 -14
- package/src/_internal/registry.ts +28 -1
- package/src/_internal/setup-lock.ts +103 -0
- package/src/ai/chat/action-card.ts +69 -3
- package/src/ai/chat/action-question.tsx +12 -1
- package/src/ai/chat/action-text.ts +288 -0
- package/src/ai/chat/tool-action-words.ts +31 -0
- package/src/ai/chat/tool-action.tsx +1 -26
- package/src/ai/executor.ts +10 -2
- package/src/ai/firecrawl-tools.ts +15 -2
- package/src/ai/index.ts +1 -0
- package/src/ai/migrate.ts +15 -0
- package/src/ai/open-questions.ts +172 -0
- package/src/ai/question-text.ts +8 -0
- package/src/ai/routes.ts +51 -5
- package/src/ai/store.ts +18 -0
- package/src/ai/testing.ts +36 -0
- package/src/ai/tool-sentences.ts +152 -34
- package/src/ai/tool-wording.ts +12 -6
- package/src/ai/tools.ts +8 -3
- package/src/ai/types.ts +16 -2
- package/src/api/app-approval-actions.ts +31 -7
- package/src/api/auth/schemas.ts +3 -0
- package/src/api/auth.ts +54 -18
- package/src/api/mcp.ts +27 -3
- package/src/api/me.ts +79 -3
- package/src/approvals/Approvals.tsx +25 -63
- package/src/approvals/approval-card.ts +68 -0
- package/src/contracts/app-approval.ts +3 -3
- package/src/contracts/capabilities.ts +1 -1
- package/src/contracts/shared.ts +3 -0
- package/src/server/middleware/auth.ts +4 -2
- package/src/services/accounts/app.ts +23 -3
- package/src/services/accounts/users.ts +12 -1
- package/src/services/approvals/device.ts +49 -33
- package/src/services/approvals/index.ts +1 -0
- package/src/services/approvals/list.ts +1 -1
- package/src/services/approvals/question.ts +68 -0
- package/src/services/audit/index.ts +1 -0
- package/src/services/auth-flows/ipa.ts +39 -2
- package/src/services/auth-flows/password-reset.ts +32 -6
- package/src/services/index.ts +2 -0
- package/src/services/pdf/markdown.ts +160 -2
- package/src/services/session/index.ts +203 -29
- package/src/ssr/HelpPanelArticle.island.tsx +308 -0
- package/src/ssr/HelpPanelBack.island.tsx +32 -0
- package/src/ssr/HelpPanelMenu.island.tsx +133 -0
- package/src/ssr/HelpPanelSearch.island.tsx +123 -0
- package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
- package/src/ssr/HelpPanelView.island.tsx +27 -0
- package/src/ssr/LayoutHelp.tsx +182 -540
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
- package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
- package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
- package/src/ssr/help.ts +4 -3
- package/src/ssr/index.ts +1 -0
- package/src/ssr/islands/index.ts +3 -4
- package/src/ssr/layout-help-panel-controls.tsx +43 -0
- package/src/ssr/layout-help-panel-document.ts +67 -0
- package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
- package/src/ssr/layout-help-panel-menu.tsx +113 -0
- package/src/ssr/layout-help-panel-modules.ts +7 -0
- package/src/ssr/layout-help-panel-reader.tsx +159 -0
- package/src/ssr/layout-help-panel-search.tsx +69 -0
- package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
- package/src/ssr/layout-help-panel-state.ts +141 -0
- package/src/ssr/layout-help-panel-types.ts +19 -0
- package/src/ssr/layout-help-views.tsx +484 -0
- package/src/ssr/panel-idle.ts +9 -0
- package/src/styles/approvals.css +6 -0
- 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
|
|
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
|
-
/**
|
|
368
|
+
/** The plain Assistant question, in the originating turn's language. */
|
|
369
369
|
sentence: PlainText(APP_ACTION_LIMITS.sentenceChars).min(1),
|
|
370
|
-
/** The
|
|
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(
|
|
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>;
|
package/src/contracts/shared.ts
CHANGED
|
@@ -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"
|
|
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: {
|
|
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
|
-
|
|
879
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
48
|
-
|
|
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
|
-
|
|
69
|
+
for (const field of question.rows) {
|
|
55
70
|
if (details.length >= limits.details) {
|
|
56
71
|
truncated = true;
|
|
57
|
-
|
|
72
|
+
break;
|
|
58
73
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
details.push({ label
|
|
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(
|
|
81
|
-
const boundedSentence = text(
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/services/index.ts
CHANGED