@k2b/cloud 0.30.0 → 0.32.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 (171) hide show
  1. package/package.json +7 -4
  2. package/src/_internal/app-presentation.ts +22 -1
  3. package/src/_internal/capabilities.ts +2 -2
  4. package/src/_internal/define-app.ts +17 -5
  5. package/src/_internal/registry.ts +50 -6
  6. package/src/_internal/widget-declarations.ts +53 -0
  7. package/src/ai/app-skills.ts +1 -7
  8. package/src/ai/approval-routes.ts +40 -4
  9. package/src/ai/approvals.ts +68 -10
  10. package/src/ai/audio-tool.ts +2 -1
  11. package/src/ai/browser.ts +109 -0
  12. package/src/ai/capabilities.ts +6 -2
  13. package/src/ai/chart-block.ts +431 -81
  14. package/src/ai/chat/action-sentences.ts +13 -7
  15. package/src/ai/chat/blocks.tsx +64 -39
  16. package/src/ai/chat/builtin-tools.tsx +9 -7
  17. package/src/ai/chat/chart-block.tsx +218 -44
  18. package/src/ai/chat/explorer-block.tsx +276 -0
  19. package/src/ai/chat/file-tools.tsx +40 -1
  20. package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
  21. package/src/ai/chat/message-actions.tsx +9 -1
  22. package/src/ai/chat/message-utils.ts +7 -0
  23. package/src/ai/chat/messages.ts +34 -0
  24. package/src/ai/chat/presentation.tsx +13 -3
  25. package/src/ai/chat/reference-text.tsx +34 -0
  26. package/src/ai/chat/tool-groups.ts +67 -12
  27. package/src/ai/chat/tool-names.ts +18 -2
  28. package/src/ai/chat/turn-layout.ts +8 -0
  29. package/src/ai/chat/turn-view.tsx +111 -32
  30. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  31. package/src/ai/chat/view-block.tsx +417 -0
  32. package/src/ai/chat/visual-tools.tsx +32 -53
  33. package/src/ai/chat/web-tools.tsx +95 -1
  34. package/src/ai/client/controller.ts +6 -3
  35. package/src/ai/client-tool-results.ts +9 -0
  36. package/src/ai/code-runtime-tools.ts +50 -17
  37. package/src/ai/code-source-contracts.ts +25 -21
  38. package/src/ai/code-source-tools.ts +3 -1
  39. package/src/ai/default-tool-contracts.ts +334 -91
  40. package/src/ai/default-tools.ts +87 -11
  41. package/src/ai/executor.ts +343 -45
  42. package/src/ai/fetch-file-tool.ts +41 -4
  43. package/src/ai/file-error.ts +2 -0
  44. package/src/ai/file-tools.ts +27 -7
  45. package/src/ai/files-store.ts +60 -0
  46. package/src/ai/firecrawl-tools.ts +87 -4
  47. package/src/ai/html-pdf-tool.ts +3 -2
  48. package/src/ai/http.ts +37 -12
  49. package/src/ai/index.ts +98 -0
  50. package/src/ai/local-tool-contracts.ts +244 -0
  51. package/src/ai/malformed-tool-call-repair.ts +144 -0
  52. package/src/ai/markdown-pdf-tool.ts +2 -1
  53. package/src/ai/migrate.ts +120 -2
  54. package/src/ai/openai-api-messages.ts +53 -0
  55. package/src/ai/openai-api-slots.ts +40 -0
  56. package/src/ai/openai-api.ts +383 -0
  57. package/src/ai/protocol.ts +28 -3
  58. package/src/ai/provider.ts +16 -4
  59. package/src/ai/request-context.ts +33 -0
  60. package/src/ai/routes.ts +99 -49
  61. package/src/ai/runtime.ts +90 -15
  62. package/src/ai/settings.ts +5 -0
  63. package/src/ai/skill-catalog.ts +20 -5
  64. package/src/ai/skill-format.ts +17 -7
  65. package/src/ai/skill-tool.ts +15 -5
  66. package/src/ai/skills-routes.ts +20 -6
  67. package/src/ai/skills.ts +26 -7
  68. package/src/ai/store.ts +91 -1
  69. package/src/ai/system-prompt.ts +69 -10
  70. package/src/ai/timeline.ts +3 -1
  71. package/src/ai/tool-audit.ts +11 -1
  72. package/src/ai/tools.ts +94 -3
  73. package/src/ai/types.ts +48 -0
  74. package/src/ai/view-block.ts +941 -0
  75. package/src/ai/vision-tool.ts +2 -1
  76. package/src/ai/web-provenance.ts +103 -0
  77. package/src/api/admin-identity.ts +6 -1
  78. package/src/api/app-approval-actions.ts +36 -0
  79. package/src/api/app-approval.ts +10 -2
  80. package/src/api/approvals-loader.ts +15 -0
  81. package/src/api/approvals.ts +120 -0
  82. package/src/api/capabilities.ts +3 -2
  83. package/src/api/index.ts +3 -0
  84. package/src/api/me.ts +12 -3
  85. package/src/api/widgets.ts +36 -5
  86. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  87. package/src/approvals/Approvals.tsx +621 -0
  88. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  89. package/src/approvals/messages.ts +95 -0
  90. package/src/approvals/ui.ts +8 -0
  91. package/src/browser/CloudResourceSearch.tsx +1 -1
  92. package/src/browser/app-approval.ts +32 -0
  93. package/src/browser/choose-files.tsx +26 -13
  94. package/src/browser/command-shortcuts.ts +21 -6
  95. package/src/browser/commands.ts +13 -4
  96. package/src/browser/file-provider-error.ts +11 -0
  97. package/src/browser/file-providers.ts +3 -12
  98. package/src/browser/files.tsx +1 -2
  99. package/src/browser/live-connection.ts +2 -3
  100. package/src/browser/provider-list.ts +2 -1
  101. package/src/browser/save-files.tsx +13 -2
  102. package/src/browser/spotlight-position.ts +4 -1
  103. package/src/browser/widgets.ts +7 -5
  104. package/src/capabilities/executions.ts +3 -0
  105. package/src/cli/account.ts +21 -2
  106. package/src/cli/commands.ts +24 -2
  107. package/src/cli/index.ts +16 -0
  108. package/src/cli/plugin.ts +30 -0
  109. package/src/contracts/app-approval.ts +120 -0
  110. package/src/contracts/app.ts +34 -2
  111. package/src/contracts/approval-paths.ts +4 -0
  112. package/src/contracts/approvals.ts +81 -0
  113. package/src/contracts/command-path.ts +17 -0
  114. package/src/contracts/commands.ts +5 -15
  115. package/src/contracts/registry.ts +9 -1
  116. package/src/contracts/shared.ts +5 -0
  117. package/src/contracts/widgets.ts +47 -2
  118. package/src/desktop/solid.tsx +6 -7
  119. package/src/events/live-protocol.ts +2 -1
  120. package/src/events/live-server-message.ts +40 -0
  121. package/src/events/testing.ts +88 -0
  122. package/src/server/index.ts +3 -0
  123. package/src/server/middleware/auth.ts +11 -1
  124. package/src/server/request-timeout.ts +22 -0
  125. package/src/server/services/access.ts +4 -3
  126. package/src/server/widget-request.ts +27 -0
  127. package/src/services/app-approval.ts +103 -15
  128. package/src/services/approvals/cursor.ts +15 -0
  129. package/src/services/approvals/device.ts +112 -0
  130. package/src/services/approvals/index.ts +241 -0
  131. package/src/services/approvals/list.ts +120 -0
  132. package/src/services/approvals/migrate.ts +41 -0
  133. package/src/services/identity/models-only-auth.ts +25 -0
  134. package/src/services/notifications/email-frame.ts +16 -3
  135. package/src/services/service-account-credentials.ts +22 -10
  136. package/src/services/settings/core-settings.ts +27 -0
  137. package/src/shared/ai-platform-prompt.ts +26 -1
  138. package/src/shared/app-presentation.ts +11 -0
  139. package/src/shared/email-html.ts +46 -7
  140. package/src/shared/index.ts +6 -1
  141. package/src/shared/markdown/extensions/code.ts +14 -0
  142. package/src/shared/markdown/index.ts +4 -4
  143. package/src/ssr/AdminLayout.tsx +4 -7
  144. package/src/ssr/AppBadge.tsx +4 -4
  145. package/src/ssr/AppLaunchpad.island.tsx +1 -1
  146. package/src/ssr/AppLaunchpadPanel.tsx +4 -4
  147. package/src/ssr/Layout.tsx +38 -14
  148. package/src/ssr/LayoutFooter.tsx +1 -1
  149. package/src/ssr/LayoutHeader.tsx +17 -20
  150. package/src/ssr/LayoutHelp.tsx +1 -1
  151. package/src/ssr/LayoutRail.tsx +1 -2
  152. package/src/ssr/PageTrail.tsx +23 -0
  153. package/src/ssr/PageTrailRow.island.tsx +20 -0
  154. package/src/ssr/PageTrailRuntime.island.tsx +26 -0
  155. package/src/ssr/ProfilePreferences.island.tsx +7 -3
  156. package/src/ssr/RailApps.island.tsx +2 -2
  157. package/src/ssr/app-badges.ts +50 -6
  158. package/src/ssr/islands/index.ts +1 -0
  159. package/src/ssr/layout-runtime.ts +60 -0
  160. package/src/ssr/mobile-menu-history.ts +1 -1
  161. package/src/ssr/page-trail-runtime.ts +313 -0
  162. package/src/ssr/page-trail.browser-harness.tsx +265 -0
  163. package/src/ssr/page-trail.ts +59 -0
  164. package/src/ssr/platform-messages.ts +4 -0
  165. package/src/ssr/rail-navigation.ts +5 -3
  166. package/src/ssr/runtime.ts +6 -1
  167. package/src/styles/approvals.css +390 -0
  168. package/src/styles/effects.css +21 -4
  169. package/src/styles/global.css +39 -3
  170. package/src/styles/utilities-layout.css +24 -1
  171. package/src/styles/utilities-navigation.css +6 -18
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { z } from "zod";
3
+ import { aiMissingChatFileError } from "./file-error";
3
4
  import { aiProjectFilePathFromMount } from "./file-mount";
4
5
  import { aiFileStore, normalizeAiFilePath } from "./files-store";
5
6
  import { PdfPages, renderPdfPages } from "./pdf-render";
@@ -53,7 +54,7 @@ export const createCloudAiViewImageTool = (options: { resolveModel?: () => Promi
53
54
  if (snapshotRequired && !snapshot) throw new Error(`Attached image snapshot is unavailable: ${path}`);
54
55
  const stored =
55
56
  projectFile ?? snapshot ?? (projectPath === null ? await aiFileStore.read({ conversationId: ctx.conversationId, path }) : null);
56
- if (!stored) throw new Error(`No such file: ${path}`);
57
+ if (!stored) throw aiMissingChatFileError(path, ctx.localToolsEnabled);
57
58
  const pdf = stored.mediaType === "application/pdf";
58
59
  if (!pdf && !isAiImageMediaType(stored.mediaType)) throw new Error(`${path} is not a supported image or PDF (${stored.mediaType}).`);
59
60
  if (!pdf && input.pages !== undefined) throw new Error("pages can only be used with a PDF.");
@@ -0,0 +1,103 @@
1
+ import { domainToUnicode } from "node:url";
2
+ import { sql } from "bun";
3
+ import { toPgTextArray } from "../services/postgres";
4
+
5
+ /** The longest address web_extract and fetch_file accept; a longer one can never be read, so it is never recorded. */
6
+ const WEB_ADDRESS_MAX_CHARS = 2_000;
7
+
8
+ const URL_TOKEN = /https?:\/\/[^\s<>"'`\\^{}|[\]]+/gi;
9
+ /**
10
+ * An address typed without its scheme, such as `example.org/news`. It must start the word: a host inside another
11
+ * address, an email address, or a longer host name is not a typed address of its own.
12
+ */
13
+ const BARE_TOKEN =
14
+ /(?<![\w@.\-/:%+~#=?&])(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z][a-z0-9-]*[a-z0-9](?::\d{1,5})?(?:\/[^\s<>"'`\\^{}|[\]]*)?/gi;
15
+
16
+ /** Drops punctuation that ends the sentence around an address, and a closing bracket the address did not open. */
17
+ const trimToken = (token: string): string => {
18
+ let end = token.length;
19
+ while (end > 0) {
20
+ const last = token[end - 1]!;
21
+ if (".,;:!?*'\"".includes(last)) end--;
22
+ else if (last === ")" && token.slice(0, end).split("(").length <= token.slice(0, end).split(")").length - 1) end--;
23
+ else break;
24
+ }
25
+ return token.slice(0, end);
26
+ };
27
+
28
+ /** The forms of one address that count as the same read: as written, without its fragment, and over HTTPS. */
29
+ const addressForms = (raw: string, add: (address: string) => void) => {
30
+ let url: URL;
31
+ try {
32
+ url = new URL(raw);
33
+ } catch {
34
+ return;
35
+ }
36
+ if (url.protocol !== "https:" && url.protocol !== "http:") return;
37
+ const forms = [url.href];
38
+ if (url.hash) forms.push(url.href.slice(0, -url.hash.length));
39
+ // Upgrading to HTTPS carries no data the address did not already carry.
40
+ if (url.protocol === "http:") forms.push(...forms.map((form) => `https:${form.slice("http:".length)}`));
41
+ for (const form of forms) if (form.length <= WEB_ADDRESS_MAX_CHARS) add(form);
42
+ };
43
+
44
+ /**
45
+ * Every whole address in a text, normalized like a URL the tools read. Only complete tokens count, never a part of a
46
+ * longer address; with `typed`, an address written without its scheme counts too, as a person often types it.
47
+ */
48
+ export const webAddressesIn = (text: string, options: { typed?: boolean } = {}): Set<string> => {
49
+ const addresses = new Set<string>();
50
+ const add = (address: string) => addresses.add(address);
51
+ const rest = text.replace(URL_TOKEN, (token) => {
52
+ addressForms(trimToken(token), add);
53
+ return " ";
54
+ });
55
+ if (options.typed)
56
+ for (const [token] of rest.matchAll(BARE_TOKEN)) {
57
+ const bare = trimToken(token);
58
+ addressForms(`https://${bare}`, add);
59
+ addressForms(`http://${bare}`, add);
60
+ }
61
+ return addresses;
62
+ };
63
+
64
+ /** Records addresses a web search returned or a page read in this chat links to; later reads of them do not ask. */
65
+ export const recordWebAddresses = async (conversationId: string, addresses: Iterable<string>): Promise<void> => {
66
+ const list = [...addresses];
67
+ if (!list.length) return;
68
+ await sql`
69
+ INSERT INTO ai.web_addresses (conversation_id, address)
70
+ SELECT ${conversationId}::uuid, address FROM unnest(${toPgTextArray(list)}::text[]) address
71
+ ON CONFLICT DO NOTHING
72
+ `;
73
+ };
74
+
75
+ /**
76
+ * Whether the chat itself supplied this address: the person wrote it, a web search returned it, or a page read
77
+ * earlier links to it. A message another chat sent counts as neither. Anything else, such as an address the model
78
+ * assembled from other data, has no provenance. `url` is the normalized address the tool reads.
79
+ */
80
+ export const hasWebAddressProvenance = async (conversationId: string, url: string): Promise<boolean> => {
81
+ const [recorded] = await sql<{ found: boolean }[]>`
82
+ SELECT EXISTS (SELECT 1 FROM ai.web_addresses WHERE conversation_id = ${conversationId}::uuid AND address = ${url}) AS found
83
+ `;
84
+ if (recorded?.found) return true;
85
+ // Only the person's own messages that name the host are read, never tool results.
86
+ const { hostname } = new URL(url);
87
+ const hosts = [...new Set([hostname, domainToUnicode(hostname) || hostname])];
88
+ const rows = await sql<{ text: string }[]>`
89
+ SELECT text FROM (
90
+ SELECT message, meta FROM ai.messages WHERE conversation_id = ${conversationId}::uuid AND kind = 'message' AND role = 'user'
91
+ UNION ALL
92
+ SELECT message, meta FROM ai.task_messages WHERE conversation_id = ${conversationId}::uuid AND kind = 'message' AND role = 'user'
93
+ ) entry
94
+ CROSS JOIN LATERAL (
95
+ SELECT string_agg(CASE WHEN jsonb_typeof(part) = 'string' THEN part #>> '{}' ELSE part->>'text' END, chr(10)) AS text
96
+ FROM jsonb_array_elements(CASE WHEN jsonb_typeof(entry.message->'content') = 'array' THEN entry.message->'content' ELSE '[]'::jsonb END) part
97
+ WHERE jsonb_typeof(part) = 'string' OR part->>'type' = 'text'
98
+ ) content
99
+ WHERE NOT COALESCE(entry.meta ? 'agentMessage', false)
100
+ AND EXISTS (SELECT 1 FROM unnest(${toPgTextArray(hosts)}::text[]) host WHERE strpos(lower(content.text), host) > 0)
101
+ `;
102
+ return rows.some((row) => webAddressesIn(row.text, { typed: true }).has(url));
103
+ };
@@ -5,6 +5,7 @@ import { describeRoute } from "hono-openapi";
5
5
  import { z } from "zod";
6
6
  import { listApps } from "../_internal/registry";
7
7
  import { CapabilityAppIdSchema } from "../contracts/capabilities";
8
+ import { AI_INFERENCE_SCOPE } from "../contracts/shared";
8
9
  import { type AuthContext, auth, err, respond, v } from "../server";
9
10
  import { audit } from "../services/audit";
10
11
  import { getIdentitySigningKeyStatus, identityMetrics, revokeIdentitySigningKey, rewrapIdentitySigningKeys } from "../services/identity";
@@ -63,7 +64,11 @@ const ServiceAccountStatusInputSchema = z.object({ status: z.enum(["active", "di
63
64
  const ServiceAccountApiKeyInputSchema = z
64
65
  .object({
65
66
  name: z.string().trim().min(1).max(120),
66
- scopes: z.array(z.string().trim().min(1).max(64)).max(20).default([]),
67
+ scopes: z
68
+ .array(z.string().trim().min(1).max(64))
69
+ .max(20)
70
+ .default([])
71
+ .refine((scopes) => !scopes.includes(AI_INFERENCE_SCOPE), "ai:inference is reserved for models-only personal API keys"),
67
72
  expiresAt: z.iso.datetime().nullable().optional(),
68
73
  })
69
74
  .strict();
@@ -0,0 +1,36 @@
1
+ import { submitAiTurnAction } from "../ai/runtime";
2
+ import { aiConversations } from "../ai/store";
3
+ import { APP_ACTION_LIMITS } from "../contracts/app-approval";
4
+ import type { AppDeviceActions } from "../services/app-approval";
5
+ import { listPending } from "../services/approvals";
6
+ import { appActionSummary, appActionView } from "../services/approvals/device";
7
+
8
+ export const createDeviceActions = (appName: (appId: string, locale: string) => string): AppDeviceActions => {
9
+ const view: AppDeviceActions["view"] = async ({ userId, issuer, actionId }) => {
10
+ const { items } = await listPending({ approverUserId: userId, id: actionId, limit: 1 });
11
+ return items[0] ? appActionView(items[0], { issuer, appName }) : null;
12
+ };
13
+ return {
14
+ inbox: async ({ userId, issuer }) => {
15
+ const { items, count } = await listPending({ approverUserId: userId, limit: APP_ACTION_LIMITS.inboxActions });
16
+ const views = await Promise.all(items.map((row) => appActionView(row, { issuer, appName })));
17
+ return { actions: views.map(appActionSummary), moreActions: Math.max(0, count - items.length) };
18
+ },
19
+ view,
20
+ decide: async ({ userId, deviceId, issuer, actionId, digest, approved }) => {
21
+ const current = await view({ userId, issuer, actionId });
22
+ if (!current || current.digest !== digest) return false;
23
+ const pending = await aiConversations.findPendingTurnActionByApproval({ approvalId: actionId, ownerUserId: userId });
24
+ if (!pending) return false;
25
+ const result = await submitAiTurnAction({
26
+ conversationId: pending.conversationId,
27
+ turnId: pending.turnId,
28
+ callId: pending.callId,
29
+ action: { type: "approval_response", approved },
30
+ decidedBy: { userId, via: "cloud_login", deviceId },
31
+ signedInSession: false,
32
+ });
33
+ return result.ok;
34
+ },
35
+ };
36
+ };
@@ -16,13 +16,15 @@ import {
16
16
  } from "../contracts/app-approval";
17
17
  import { type AuthContext, auth, rateLimit, v } from "../server";
18
18
  import { type AppApprovalActor, AppApprovalError, appApproval, type createAppApprovalService } from "../services/app-approval";
19
+ import { appNameIn } from "../ssr/runtime";
20
+ import { createDeviceActions } from "./app-approval-actions";
19
21
 
20
22
  const PairingIdSchema = z.object({ pairingId: z.string().uuid() }).strict();
21
23
  const DevicePageSchema = z.object({ after: z.string().uuid().optional() }).strict();
22
24
  type Service = ReturnType<typeof createAppApprovalService>;
23
25
 
24
26
  /** This is a separate credential surface, never a user API-key or OAuth scope. */
25
- export const createAppApprovalRoutes = (service: Service = appApproval) => {
27
+ export const createAppApprovalRoutes = (service: Service = appApproval, actions = createDeviceActions) => {
26
28
  const actor = async (c: Parameters<typeof auth.session.getToken>[0]): Promise<AppApprovalActor> => {
27
29
  // The web session (or an explicit bearer session), never the mobile app's session: a phone
28
30
  // that pairs sign-in devices would hand out authority that outlives its removal.
@@ -104,7 +106,13 @@ export const createAppApprovalRoutes = (service: Service = appApproval) => {
104
106
  "/device",
105
107
  describeRoute({ tags: ["App approval"], summary: "Execute a one-use signed device command" }),
106
108
  v("json", AppDeviceRequestSchema),
107
- async (c) => c.json(await service.deviceCommand(c.req.valid("json"))),
109
+ async (c) =>
110
+ c.json(
111
+ await service.deviceCommand(
112
+ c.req.valid("json"),
113
+ actions((appId, locale) => appNameIn(c, appId, locale, appId)),
114
+ ),
115
+ ),
108
116
  )
109
117
  .post(
110
118
  "/login/start",
@@ -0,0 +1,15 @@
1
+ import type { Context } from "hono";
2
+ import { HTTPException } from "hono/http-exception";
3
+ import { resolveAiChatRequestContext } from "../ai/request-context";
4
+ import type { ApprovalListQuery, ApprovalPage } from "../contracts/approvals";
5
+ import type { AuthContext } from "../server/middleware/auth";
6
+ import { listPending, presentPendingApproval } from "../services/approvals/list";
7
+ import { getLocalizedRuntimeContext } from "../ssr/runtime";
8
+
9
+ export const loadPendingApprovals = async <T extends AuthContext>(c: Context<T>, query: ApprovalListQuery): Promise<ApprovalPage> => {
10
+ const ctx = await resolveAiChatRequestContext(c);
11
+ if (ctx instanceof Response) throw new HTTPException(403, { res: ctx });
12
+ const page = await listPending({ approverUserId: ctx.ownerUserId, appId: query.app, link: query.link, cursor: query.cursor });
13
+ const apps = getLocalizedRuntimeContext(c).apps;
14
+ return { ...page, items: page.items.map((row) => presentPendingApproval(row, apps)) };
15
+ };
@@ -0,0 +1,120 @@
1
+ import { Hono } from "hono";
2
+ import { bodyLimit } from "hono/body-limit";
3
+ import { describeRoute } from "hono-openapi";
4
+ import { z } from "zod";
5
+ import { toAiActionFailureResponse } from "../ai/http";
6
+ import { resolveAiChatRequestContext } from "../ai/request-context";
7
+ import { submitAiTurnAction } from "../ai/runtime";
8
+ import { aiConversations } from "../ai/store";
9
+ import {
10
+ APPROVAL_CLIENT_HEADER,
11
+ ApprovalCountsSchema,
12
+ ApprovalDecisionInputSchema,
13
+ ApprovalDecisionResultSchema,
14
+ ApprovalListQuerySchema,
15
+ ApprovalPageSchema,
16
+ } from "../contracts/approvals";
17
+ import { ErrorResponseSchema } from "../contracts/shared";
18
+ import { rateLimit } from "../server";
19
+ import { respond } from "../server/api";
20
+ import { type AuthContext, auth } from "../server/middleware/auth";
21
+ import { jsonResponse, requiresAuth } from "../server/middleware/openapi";
22
+ import { v } from "../server/middleware/validator";
23
+ import { ok } from "../server/services";
24
+ import { approvalDecisionChannel, countPending } from "../services/approvals";
25
+ import { decodeApprovalCursor } from "../services/approvals/cursor";
26
+ import { loadPendingApprovals } from "./approvals-loader";
27
+
28
+ export const approvalRoutes = new Hono<AuthContext>()
29
+ .use(rateLimit())
30
+ .use(auth.requireRole("authenticated"))
31
+ .get(
32
+ "/",
33
+ describeRoute({
34
+ tags: ["Approvals"],
35
+ summary: "List pending Action approvals",
36
+ ...requiresAuth,
37
+ responses: {
38
+ 200: jsonResponse(ApprovalPageSchema, "Pending approvals"),
39
+ 400: jsonResponse(ErrorResponseSchema, "Invalid query"),
40
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
41
+ 403: jsonResponse(ErrorResponseSchema, "User-backed actor required"),
42
+ },
43
+ }),
44
+ v(
45
+ "query",
46
+ ApprovalListQuerySchema.refine((query) => !query.cursor || decodeApprovalCursor(query.cursor) !== null, "Invalid approval cursor."),
47
+ ),
48
+ async (c) => respond(c, ok(await loadPendingApprovals(c, c.req.valid("query")))),
49
+ )
50
+ .get(
51
+ "/counts",
52
+ describeRoute({
53
+ tags: ["Approvals"],
54
+ summary: "Count pending Action approvals",
55
+ ...requiresAuth,
56
+ responses: {
57
+ 200: jsonResponse(ApprovalCountsSchema, "Approval counts"),
58
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
59
+ 403: jsonResponse(ErrorResponseSchema, "User-backed actor required"),
60
+ },
61
+ }),
62
+ async (c) => {
63
+ const ctx = await resolveAiChatRequestContext(c);
64
+ if (ctx instanceof Response) return ctx;
65
+ return respond(c, ok(await countPending({ approverUserId: ctx.ownerUserId })));
66
+ },
67
+ )
68
+ .post(
69
+ "/:id/decision",
70
+ bodyLimit({ maxSize: 1024 }),
71
+ describeRoute({
72
+ tags: ["Approvals"],
73
+ summary: "Decide an Action approval and continue its chat",
74
+ ...requiresAuth,
75
+ responses: {
76
+ 200: jsonResponse(ApprovalDecisionResultSchema, "Approval decision"),
77
+ 400: jsonResponse(ErrorResponseSchema, "Invalid decision"),
78
+ 401: jsonResponse(ErrorResponseSchema, "Authentication required"),
79
+ 403: jsonResponse(ErrorResponseSchema, "Decision refused"),
80
+ 404: jsonResponse(ErrorResponseSchema, "Open approval not found"),
81
+ 409: jsonResponse(ErrorResponseSchema, "Already decided differently"),
82
+ 413: jsonResponse(ErrorResponseSchema, "Request body too large"),
83
+ },
84
+ }),
85
+ v("param", z.object({ id: z.uuid() })),
86
+ v("json", ApprovalDecisionInputSchema),
87
+ async (c) => {
88
+ const ctx = await resolveAiChatRequestContext(c);
89
+ if (ctx instanceof Response) return ctx;
90
+ const pending = await aiConversations.findPendingTurnActionByApproval({
91
+ approvalId: c.req.valid("param").id,
92
+ ownerUserId: ctx.ownerUserId,
93
+ });
94
+ if (!pending)
95
+ return toAiActionFailureResponse(c, {
96
+ ok: false,
97
+ status: 404,
98
+ message: "This request has expired — the assistant already moved on.",
99
+ });
100
+ const decision = c.req.valid("json");
101
+ const result = await submitAiTurnAction({
102
+ conversationId: pending.conversationId,
103
+ turnId: pending.turnId,
104
+ callId: pending.callId,
105
+ action: { type: "approval_response", ...decision },
106
+ decidedBy: {
107
+ userId: ctx.ownerUserId,
108
+ via: approvalDecisionChannel({
109
+ credentialKind: c.get("credentialKind"),
110
+ actor: ctx.actor,
111
+ client: c.req.header(APPROVAL_CLIENT_HEADER),
112
+ }),
113
+ },
114
+ toolApprovalContext: ctx.toolApprovalContext,
115
+ signedInSession: ctx.signedInSession,
116
+ });
117
+ if (!result.ok) return toAiActionFailureResponse(c, result);
118
+ return respond(c, ok({ status: decision.approved ? ("approved" as const) : ("rejected" as const) }));
119
+ },
120
+ );
@@ -613,11 +613,12 @@ const runCapabilityDispatch = async (
613
613
  : schemaValidator(`${operation.schemaHash}:result`, capabilityResultJsonSchema(operation.dataSchema));
614
614
  const parsedResult = resultValidator?.safeParse(upstreamBody.data);
615
615
  const parsedReview = params.review ? CapabilityActionReviewSchema.safeParse(upstreamBody.data) : null;
616
+ // A rememberable Action may leave out its scope for arguments that should ask every time; any other Action has none.
616
617
  const reviewApprovalScopeIsValid =
617
618
  !params.review ||
618
- !("approval" in operation) ||
619
619
  !parsedReview?.success ||
620
- (operation.approval === "rememberable" ? parsedReview.data.approvalScope !== undefined : parsedReview.data.approvalScope === undefined);
620
+ ("approval" in operation && operation.approval === "rememberable") ||
621
+ parsedReview.data.approvalScope === undefined;
621
622
  if (!resultValidator || !parsedResult?.success || !reviewApprovalScopeIsValid) {
622
623
  if (actionWithoutRetrySafety) return settle(outcomeUnknown(), "uncertain");
623
624
  const invalid = errorResponse("INVALID_APP_RESPONSE", messages.outsideResultSchema({ appId: params.appId }), 502);
package/src/api/index.ts CHANGED
@@ -33,6 +33,7 @@ import adminOutgoingMailRoutes from "./admin-outgoing-mail";
33
33
  import adminRailRoutes from "./admin-rail";
34
34
  import { adminAnnouncementRoutes, announcementRoutes } from "./announcements";
35
35
  import { createAppApprovalRoutes } from "./app-approval";
36
+ import { approvalRoutes } from "./approvals";
36
37
  import { appDiscoveryRoutes } from "./apps";
37
38
  import { createAuthRoutes } from "./auth";
38
39
  import { createCapabilityRoutes } from "./capabilities";
@@ -44,6 +45,7 @@ import { createSearchRoutes } from "./search";
44
45
  import { createSyncOpsProxyRoutes } from "./sync-ops";
45
46
  import { createWidgetRoutes } from "./widgets";
46
47
 
48
+ export { createAiOpenAiRoutes } from "../ai/openai-api";
47
49
  export { type CapabilityDispatchDependencies, dispatchCapability } from "./capabilities";
48
50
  export { cliPluginListRoutes } from "./cli-plugins";
49
51
  export { createPwaPhoneRoutes } from "./pwa-phone";
@@ -72,6 +74,7 @@ const buildCoreApi = (options: CoreApiOptions) => {
72
74
  .route("/me", meRoutes)
73
75
  .route("/accounts", accountsEntitiesRoutes)
74
76
  .route("/apps", appDiscoveryRoutes)
77
+ .route("/approvals", approvalRoutes)
75
78
  .route("/announcements", announcementRoutes)
76
79
  .route("/admin/core/ai-usage", adminAiUsageRoutes)
77
80
  .route("/admin/core/ai-quotas", adminAiQuotaRoutes)
package/src/api/me.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * third-party management lives in the accounts admin app.
5
5
  */
6
6
 
7
- import { ok } from "@k2b/stdlib";
7
+ import { err, fail, ok } from "@k2b/stdlib";
8
8
  import { type Context, Hono, type MiddlewareHandler } from "hono";
9
9
  import { createMiddleware } from "hono/factory";
10
10
  import { describeRoute } from "hono-openapi";
@@ -51,6 +51,7 @@ import {
51
51
  webauthn,
52
52
  } from "../services";
53
53
  import { MandatePolicyV1Schema } from "../services/mandates";
54
+ import { coreSettings } from "../services/settings/api";
54
55
 
55
56
  import { createMeRailRoutes } from "./me-rail";
56
57
  import { createMeWebVitalsRoutes } from "./me-web-vitals";
@@ -614,13 +615,14 @@ const app = new Hono<AuthContext>()
614
615
  describeRoute({
615
616
  tags: ["Me"],
616
617
  summary: "Create current user API key",
617
- description: "Create a user-bound API key for the authenticated account. The raw token is returned once.",
618
+ description:
619
+ "Create a personal API key with full access (default) or models-only access to the OpenAI-compatible API when enabled. The raw token is returned once.",
618
620
  ...requiresAuth,
619
621
  responses: {
620
622
  201: jsonResponse(CreateUserApiKeyResponseSchema, "API key created"),
621
623
  400: jsonResponse(ErrorResponseSchema, "Failed to create API key"),
622
624
  401: jsonResponse(ErrorResponseSchema, "Authentication required"),
623
- 403: jsonResponse(ErrorResponseSchema, "Not permitted for API keys or other credentials"),
625
+ 403: jsonResponse(ErrorResponseSchema, "Credential not permitted or models-only API turned off"),
624
626
  },
625
627
  }),
626
628
  v("json", CreateUserApiKeySchema),
@@ -630,7 +632,14 @@ const app = new Hono<AuthContext>()
630
632
  async () => {
631
633
  const user = c.get("user");
632
634
  const data = c.req.valid("json");
635
+ if (data.access === "models" && !(await coreSettings.get<boolean>("ai.openai_api_enabled")))
636
+ return fail(
637
+ err.forbidden(
638
+ "Models-only API keys are turned off. An administrator can turn on the OpenAI-compatible API in the AI settings.",
639
+ ),
640
+ );
633
641
  return serviceAccountCredentials.createUserApiToken({
642
+ access: data.access,
634
643
  user,
635
644
  name: data.name,
636
645
  expiresAt: data.expiresAt ?? null,
@@ -2,6 +2,9 @@ import { type Context, Hono, type MiddlewareHandler } from "hono";
2
2
  import { readBoundedJson } from "../_internal/bounded-json";
3
3
  import { type DashboardWidget, listWidgets } from "../_internal/registry";
4
4
  import {
5
+ type DashboardWidgetSize,
6
+ fitWidgetSize,
7
+ isDashboardWidgetSize,
5
8
  WIDGET_MAX_RESPONSE_BYTES,
6
9
  type WidgetResponse,
7
10
  WidgetResponseSchema,
@@ -9,6 +12,7 @@ import {
9
12
  widgetKey,
10
13
  } from "../contracts/widgets";
11
14
  import { type AuthContext, auth, preferredLocale, rejectReservedWorkloadCredential } from "../server";
15
+ import { WIDGET_SIZE_PARAM } from "../server/widget-request";
12
16
  import { CLOUD_INVOCATION_TOKEN_TTL_SECONDS } from "../services/identity/constants";
13
17
  import { invocationAuthorityFromRequest } from "../services/identity/invocation-authority";
14
18
  import { widgetInvocationOperation } from "../services/identity/invocation-operations";
@@ -78,6 +82,8 @@ type ProviderOutcome =
78
82
  const callProvider = async (options: {
79
83
  c: Context<AuthContext>;
80
84
  widget: DashboardWidget;
85
+ /** What the browser asked for, fitted to the widget's declared sizes before it reaches the app. */
86
+ size: unknown;
81
87
  token: string;
82
88
  requestId: string | undefined;
83
89
  signal: AbortSignal;
@@ -98,6 +104,7 @@ const callProvider = async (options: {
98
104
  headers.set("x-cloud-invocation-operation", widgetInvocationOperation(widget.widgetId));
99
105
 
100
106
  const targetUrl = new URL(`/api/_internal/widgets/v1/${encodeURIComponent(widget.widgetId)}`, widget.url);
107
+ targetUrl.searchParams.set(WIDGET_SIZE_PARAM, fitWidgetSize(options.size, widget));
101
108
  signal.throwIfAborted();
102
109
  const response = await options.fetch(targetUrl, { headers, signal });
103
110
  if (response.status === 204 || response.status === 403) {
@@ -125,6 +132,17 @@ const callProvider = async (options: {
125
132
  }
126
133
  };
127
134
 
135
+ /**
136
+ * `<appId>/<widgetId>` with an optional `@small`, `@medium`, or `@large`, as the key and the size it asks for. The
137
+ * suffix is a size whenever the key before it is declared; `defineApp()` rejects `@` in widget IDs, so only an app
138
+ * built before sizes existed can declare an ID that ends in such a suffix, and it is still asked by that whole ID.
139
+ */
140
+ const parseRequestedWidget = (value: string, declared: ReadonlySet<string>): [string, DashboardWidgetSize | undefined] => {
141
+ const at = value.lastIndexOf("@");
142
+ const size = at < 0 ? undefined : value.slice(at + 1);
143
+ return isDashboardWidgetSize(size) && declared.has(value.slice(0, at)) ? [value.slice(0, at), size] : [value, undefined];
144
+ };
145
+
128
146
  export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) => {
129
147
  const registry = dependencies.listWidgets ?? listWidgets;
130
148
  const fetchWidget = dependencies.fetch ?? globalThis.fetch;
@@ -155,17 +173,20 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
155
173
  .use(rejectReservedWorkloadCredential)
156
174
  .use(auth.requireOAuthScope("read", "admin"))
157
175
  /**
158
- * Every requested widget (`?widget=<appId>/<widgetId>`, repeatable; all declared widgets without one) as an
159
- * NDJSON stream: one line per widget as soon as it answers, each within its own budget.
176
+ * Every requested widget (`?widget=<appId>/<widgetId>`, repeatable, optionally with `@<size>`; all declared
177
+ * widgets without one) as an NDJSON stream: one line per widget as soon as it answers, each within its own budget.
160
178
  */
161
179
  .get("/widgets/v1", async (c) => {
162
180
  const requestId = normalizeInvocationRequestId(c.req.header("x-request-id"));
163
- const requested = new Set(c.req.queries("widget") ?? []);
164
181
  const setup = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(timeoutMs)]);
165
182
  let widgets: DashboardWidget[];
183
+ let requested = new Map<string, DashboardWidgetSize | undefined>();
166
184
  try {
167
185
  const keys = new Set<string>();
168
- widgets = (await waitWithin(registry(), setup)).filter((widget) => {
186
+ const declared = await waitWithin(registry(), setup);
187
+ const declaredKeys = new Set(declared.map((widget) => widgetKey(widget.appId, widget.widgetId)));
188
+ requested = new Map((c.req.queries("widget") ?? []).map((value) => parseRequestedWidget(value, declaredKeys)));
189
+ widgets = declared.filter((widget) => {
169
190
  const key = widgetKey(widget.appId, widget.widgetId);
170
191
  if (keys.has(key) || (requested.size > 0 && !requested.has(key))) return false;
171
192
  keys.add(key);
@@ -222,6 +243,7 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
222
243
  signal: AbortSignal.any([fanoutSignal, timeout]),
223
244
  timeout,
224
245
  fetch: fetchWidget,
246
+ size: requested.get(key),
225
247
  });
226
248
  if (outcome.status === "ok") return { type: "widget", key, status: "ok", widget: outcome.widget, ms: elapsed() };
227
249
  // A widget the stream itself stopped has already been settled, and is logged once, below.
@@ -276,7 +298,16 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
276
298
  );
277
299
 
278
300
  phase = "provider";
279
- const outcome = await callProvider({ c, widget, token: signed.token, requestId, signal, timeout, fetch: fetchWidget });
301
+ const outcome = await callProvider({
302
+ c,
303
+ widget,
304
+ size: c.req.query(WIDGET_SIZE_PARAM),
305
+ token: signed.token,
306
+ requestId,
307
+ signal,
308
+ timeout,
309
+ fetch: fetchWidget,
310
+ });
280
311
  if (outcome.status === "ok") return Response.json(outcome.widget, { headers: { "content-type": "application/json" } });
281
312
  if (!("reason" in outcome))
282
313
  return new Response(null, { status: outcome.status === "empty" ? 204 : 403, headers: { "content-type": "application/json" } });
@@ -0,0 +1,16 @@
1
+ import { LocaleProvider } from "@k2b/ui";
2
+ import { render } from "solid-js/web";
3
+ import Approvals from "./Approvals";
4
+
5
+ /** Renders the real list for a browser test, which answers the approvals API with invented requests. */
6
+ const host = document.getElementById("root");
7
+ if (!host) throw new Error("Missing harness root");
8
+ const layout = new URLSearchParams(location.search).get("layout") === "band" ? "band" : "list";
9
+ render(
10
+ () => (
11
+ <LocaleProvider locale={document.documentElement.lang || "en"}>
12
+ <Approvals app="assistant" layout={layout} />
13
+ </LocaleProvider>
14
+ ),
15
+ host,
16
+ );