@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
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 {
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 }) => {
|
package/src/api/auth/schemas.ts
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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(
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
"
|
|
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(
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
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
|
-
//
|
|
710
|
+
// Both enrichments are optional: neither turns a result that fits into RESPONSE_TOO_LARGE.
|
|
691
711
|
const result = resultFor(body);
|
|
692
|
-
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
|
61
|
-
item.sentence
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
{
|
|
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:
|
|
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>{
|
|
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">
|