@k2b/cloud 0.31.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 (119) hide show
  1. package/package.json +6 -4
  2. package/src/_internal/define-app.ts +3 -1
  3. package/src/ai/app-skills.ts +1 -7
  4. package/src/ai/audio-tool.ts +2 -1
  5. package/src/ai/browser.ts +109 -0
  6. package/src/ai/capabilities.ts +6 -2
  7. package/src/ai/chart-block.ts +431 -81
  8. package/src/ai/chat/action-sentences.ts +13 -7
  9. package/src/ai/chat/blocks.tsx +11 -7
  10. package/src/ai/chat/builtin-tools.tsx +9 -7
  11. package/src/ai/chat/chart-block.tsx +218 -44
  12. package/src/ai/chat/explorer-block.tsx +276 -0
  13. package/src/ai/chat/file-tools.tsx +40 -1
  14. package/src/ai/chat/message-actions.tsx +6 -0
  15. package/src/ai/chat/message-utils.ts +7 -0
  16. package/src/ai/chat/messages.ts +16 -0
  17. package/src/ai/chat/presentation.tsx +13 -3
  18. package/src/ai/chat/reference-text.tsx +34 -0
  19. package/src/ai/chat/tool-groups.ts +1 -0
  20. package/src/ai/chat/tool-names.ts +14 -0
  21. package/src/ai/chat/turn-view.tsx +17 -7
  22. package/src/ai/chat/view-block.browser-harness.tsx +35 -0
  23. package/src/ai/chat/view-block.tsx +417 -0
  24. package/src/ai/chat/visual-tools.tsx +32 -53
  25. package/src/ai/client-tool-results.ts +9 -0
  26. package/src/ai/default-tool-contracts.ts +334 -91
  27. package/src/ai/default-tools.ts +87 -11
  28. package/src/ai/executor.ts +173 -31
  29. package/src/ai/file-error.ts +2 -0
  30. package/src/ai/file-tools.ts +27 -7
  31. package/src/ai/files-store.ts +60 -0
  32. package/src/ai/html-pdf-tool.ts +3 -2
  33. package/src/ai/http.ts +37 -12
  34. package/src/ai/index.ts +90 -0
  35. package/src/ai/local-tool-contracts.ts +244 -0
  36. package/src/ai/malformed-tool-call-repair.ts +144 -0
  37. package/src/ai/markdown-pdf-tool.ts +2 -1
  38. package/src/ai/migrate.ts +7 -0
  39. package/src/ai/openai-api-messages.ts +53 -0
  40. package/src/ai/openai-api-slots.ts +40 -0
  41. package/src/ai/openai-api.ts +383 -0
  42. package/src/ai/protocol.ts +6 -2
  43. package/src/ai/provider.ts +16 -4
  44. package/src/ai/request-context.ts +33 -0
  45. package/src/ai/routes.ts +96 -57
  46. package/src/ai/runtime.ts +48 -12
  47. package/src/ai/settings.ts +5 -0
  48. package/src/ai/skill-catalog.ts +20 -5
  49. package/src/ai/skill-format.ts +17 -7
  50. package/src/ai/skill-tool.ts +15 -5
  51. package/src/ai/skills-routes.ts +20 -6
  52. package/src/ai/skills.ts +26 -7
  53. package/src/ai/store.ts +25 -0
  54. package/src/ai/system-prompt.ts +69 -10
  55. package/src/ai/timeline.ts +3 -1
  56. package/src/ai/tools.ts +3 -0
  57. package/src/ai/types.ts +9 -0
  58. package/src/ai/view-block.ts +941 -0
  59. package/src/ai/vision-tool.ts +2 -1
  60. package/src/api/admin-identity.ts +6 -1
  61. package/src/api/app-approval-actions.ts +36 -0
  62. package/src/api/app-approval.ts +10 -2
  63. package/src/api/approvals-loader.ts +15 -0
  64. package/src/api/approvals.ts +120 -0
  65. package/src/api/index.ts +3 -0
  66. package/src/api/me.ts +12 -3
  67. package/src/approvals/Approvals.browser-harness.tsx +16 -0
  68. package/src/approvals/Approvals.tsx +621 -0
  69. package/src/approvals/ApprovalsNavStatus.tsx +20 -0
  70. package/src/approvals/messages.ts +95 -0
  71. package/src/approvals/ui.ts +8 -0
  72. package/src/browser/app-approval.ts +32 -0
  73. package/src/browser/choose-files.tsx +26 -13
  74. package/src/browser/commands.ts +13 -4
  75. package/src/browser/file-provider-error.ts +11 -0
  76. package/src/browser/file-providers.ts +3 -12
  77. package/src/browser/files.tsx +1 -2
  78. package/src/browser/live-connection.ts +2 -3
  79. package/src/browser/provider-list.ts +2 -1
  80. package/src/browser/save-files.tsx +13 -2
  81. package/src/cli/account.ts +21 -2
  82. package/src/cli/commands.ts +4 -2
  83. package/src/contracts/app-approval.ts +120 -0
  84. package/src/contracts/approval-paths.ts +4 -0
  85. package/src/contracts/approvals.ts +81 -0
  86. package/src/contracts/command-path.ts +17 -0
  87. package/src/contracts/commands.ts +5 -15
  88. package/src/contracts/shared.ts +5 -0
  89. package/src/events/live-protocol.ts +2 -1
  90. package/src/events/live-server-message.ts +40 -0
  91. package/src/events/testing.ts +88 -0
  92. package/src/server/index.ts +2 -0
  93. package/src/server/middleware/auth.ts +11 -1
  94. package/src/server/request-timeout.ts +22 -0
  95. package/src/server/services/access.ts +4 -3
  96. package/src/services/app-approval.ts +103 -15
  97. package/src/services/approvals/cursor.ts +15 -0
  98. package/src/services/approvals/device.ts +112 -0
  99. package/src/services/approvals/index.ts +27 -11
  100. package/src/services/approvals/list.ts +120 -0
  101. package/src/services/approvals/migrate.ts +9 -1
  102. package/src/services/identity/models-only-auth.ts +25 -0
  103. package/src/services/notifications/email-frame.ts +16 -3
  104. package/src/services/service-account-credentials.ts +22 -10
  105. package/src/services/settings/core-settings.ts +27 -0
  106. package/src/shared/ai-platform-prompt.ts +26 -1
  107. package/src/shared/email-html.ts +4 -0
  108. package/src/shared/index.ts +6 -1
  109. package/src/shared/markdown/extensions/code.ts +14 -0
  110. package/src/shared/markdown/index.ts +4 -4
  111. package/src/ssr/AppBadge.tsx +4 -4
  112. package/src/ssr/AppLaunchpadPanel.tsx +4 -4
  113. package/src/ssr/RailApps.island.tsx +2 -2
  114. package/src/ssr/app-badges.ts +50 -6
  115. package/src/ssr/page-trail.browser-harness.tsx +52 -3
  116. package/src/ssr/rail-navigation.ts +5 -3
  117. package/src/ssr/runtime.ts +6 -1
  118. package/src/styles/approvals.css +390 -0
  119. package/src/styles/global.css +5 -2
@@ -0,0 +1,81 @@
1
+ import { z } from "zod";
2
+ import { APP_APPROVAL_LIMITS } from "./app-approval";
3
+ import { CapabilityActionReviewSchema, CapabilityAppIdSchema } from "./capabilities";
4
+
5
+ export { APPROVAL_COUNTS_PATH, APPROVALS_API_PATH, APPROVALS_PAGE_PATH } from "./approval-paths";
6
+
7
+ /**
8
+ * Pending Action approvals as every app lists and decides them: one Core route under `/api/approvals` and the
9
+ * `Approvals` component from `@k2b/cloud/approvals/ui`. A list shows only the signed-in person's own open requests.
10
+ */
11
+
12
+ /** One page of a list, the same as a Cloud Login inbox page. */
13
+ export const APPROVALS_PAGE_SIZE = APP_APPROVAL_LIMITS.pageSize;
14
+
15
+ /**
16
+ * A client that relays decisions from an editor over the Agent Client Protocol sends this header with the value
17
+ * `acp`. Such a client may reject any request, but approves only `standard` ones. Cloud cannot verify the header: it is
18
+ * the bridge's own declaration, which keeps the editor that drives the bridge from approving a `confirm` request.
19
+ */
20
+ export const APPROVAL_CLIENT_HEADER = "x-cloud-client";
21
+ export const APPROVAL_ACP_CLIENT = "acp";
22
+
23
+ export const ApprovalListQuerySchema = z
24
+ .object({
25
+ /** Requests whose effect belongs to this app, or that this app asked for. */
26
+ app: CapabilityAppIdSchema.optional(),
27
+ /** Requests whose review links to this same-origin path, such as the draft a send Action would send. */
28
+ link: z.string().startsWith("/").max(2048).optional(),
29
+ cursor: z.string().min(1).max(200).optional(),
30
+ })
31
+ .strict();
32
+ export type ApprovalListQuery = z.infer<typeof ApprovalListQuerySchema>;
33
+
34
+ export const PendingApprovalSchema = z
35
+ .object({
36
+ id: z.uuid(),
37
+ /** The app that owns the effect, named from the registry in the reader's language. */
38
+ app: z.object({ id: z.string(), name: z.string(), icon: z.string().nullable() }).strict(),
39
+ action: z.string(),
40
+ /** `confirm` asks every time and never offers to remember; `standard` may offer it. */
41
+ assurance: z.enum(["standard", "confirm"]),
42
+ /** What the person reads: the first line names the app and the Action, later lines add `Label: value` details. */
43
+ sentence: z.string(),
44
+ /** The owning app's review as the person saw it when Cloud asked. */
45
+ review: CapabilityActionReviewSchema.nullable(),
46
+ /** Where the request waits. The title is chat content, written by a person or a model, never Cloud's own words. */
47
+ requester: z.object({ kind: z.literal("chat"), appId: z.string(), title: z.string().nullable(), href: z.string() }).strict(),
48
+ /** Ways the person may remember this approval, the same the chat card offers. */
49
+ remember: z.object({ chat: z.boolean(), always: z.boolean(), website: z.boolean() }).strict(),
50
+ createdAt: z.string(),
51
+ expiresAt: z.string(),
52
+ })
53
+ .strict();
54
+ export type PendingApproval = z.infer<typeof PendingApprovalSchema>;
55
+
56
+ export const ApprovalPageSchema = z
57
+ .object({
58
+ items: z.array(PendingApprovalSchema).max(APPROVALS_PAGE_SIZE),
59
+ nextCursor: z.string().nullable(),
60
+ /** Open requests that match the query, across all pages. */
61
+ count: z.number().int().nonnegative(),
62
+ /** Open requests of the person that do not match the query; 0 without `app` or `link`. */
63
+ elsewhere: z.number().int().nonnegative(),
64
+ })
65
+ .strict();
66
+ export type ApprovalPage = z.infer<typeof ApprovalPageSchema>;
67
+
68
+ export const ApprovalCountsSchema = z
69
+ .object({
70
+ total: z.number().int().nonnegative(),
71
+ /** Per app ID: open requests whose effect belongs to the app or that the app asked for, as `?app=` lists them. */
72
+ apps: z.record(z.string(), z.number().int().positive()),
73
+ })
74
+ .strict();
75
+ export type ApprovalCounts = z.infer<typeof ApprovalCountsSchema>;
76
+
77
+ export const ApprovalDecisionInputSchema = z.object({ approved: z.boolean(), remember: z.enum(["chat", "always"]).optional() }).strict();
78
+ export type ApprovalDecisionInput = z.infer<typeof ApprovalDecisionInputSchema>;
79
+
80
+ export const ApprovalDecisionResultSchema = z.object({ status: z.enum(["approved", "rejected"]) }).strict();
81
+ export type ApprovalDecisionResult = z.infer<typeof ApprovalDecisionResultSchema>;
@@ -0,0 +1,17 @@
1
+ /** Commands carry small public references/options, never document bodies or secrets. */
2
+ export const COMMAND_MAX_URL_LENGTH = 8192;
3
+
4
+ /** The same-origin path rule of `CommandPathSchema`, without its length limit. */
5
+ export const isCommandPathSyntax = (value: string): boolean => {
6
+ if (!value.startsWith("/") || /[\\\u0000-\u0020]/u.test(value)) return false;
7
+ try {
8
+ const url = new URL(value, "https://cloud.invalid");
9
+ // Dot segments can resolve to `//host`, which a browser reads as another host.
10
+ return url.origin === "https://cloud.invalid" && !url.pathname.startsWith("//");
11
+ } catch {
12
+ return false;
13
+ }
14
+ };
15
+
16
+ /** Whether `value` is a Cloud path that `CommandPathSchema` accepts; loads no validators. */
17
+ export const isCommandPath = (value: string): boolean => (value.length <= 2000 || [...value].length <= 2000) && isCommandPathSyntax(value);
@@ -1,20 +1,10 @@
1
1
  import { z } from "zod";
2
+ import { COMMAND_MAX_URL_LENGTH, isCommandPathSyntax } from "./command-path";
2
3
 
3
- /** Commands carry small public references/options, never document bodies or secrets. */
4
- export const COMMAND_MAX_URL_LENGTH = 8192;
5
- export const CommandPathSchema = z
6
- .string()
7
- .max(2000)
8
- .refine((value) => {
9
- if (!value.startsWith("/") || /[\\\u0000-\u0020]/u.test(value)) return false;
10
- try {
11
- const url = new URL(value, "https://cloud.invalid");
12
- // Dot segments can resolve to `//host`, which a browser reads as another host.
13
- return url.origin === "https://cloud.invalid" && !url.pathname.startsWith("//");
14
- } catch {
15
- return false;
16
- }
17
- }, "Expected a Cloud path");
4
+ export { COMMAND_MAX_URL_LENGTH, isCommandPath } from "./command-path";
5
+
6
+ // Keep the size error separate from the syntax error, as before.
7
+ export const CommandPathSchema = z.string().max(2000).refine(isCommandPathSyntax, "Expected a Cloud path");
18
8
 
19
9
  export const CommandOptionsSchema = z.object({ returnTo: CommandPathSchema.optional() }).strict();
20
10
  export type CommandOptions = z.infer<typeof CommandOptionsSchema>;
@@ -248,7 +248,12 @@ export const ServiceAccountCredentialSchema = z.object({
248
248
  });
249
249
  export type ServiceAccountCredential = z.infer<typeof ServiceAccountCredentialSchema>;
250
250
 
251
+ export const AI_INFERENCE_SCOPE = "ai:inference" as const;
252
+
253
+ export const isModelsOnlyApiKey = (credential: { scopes: readonly string[] }): boolean => credential.scopes.includes(AI_INFERENCE_SCOPE);
254
+
251
255
  export const CreateUserApiKeySchema = z.object({
256
+ access: z.enum(["full", "models"]).optional(),
252
257
  name: z.string().trim().min(1).max(120),
253
258
  expiresAt: z.string().datetime().nullable().optional(),
254
259
  });
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
 
3
+ export type { LiveServerMessage } from "./live-server-message";
4
+
3
5
  /** Messages a live socket accepts: subscriptions only. Writes go over HTTP. */
4
6
  export const LiveClientMessageSchema = z.discriminatedUnion("t", [
5
7
  z
@@ -28,4 +30,3 @@ export const LiveServerMessageSchema = z.discriminatedUnion("t", [
28
30
  z.object({ t: z.literal("revoked"), id: z.string(), code: z.enum(["not_found", "access_denied"]) }),
29
31
  z.object({ t: z.literal("error"), code: z.string(), message: z.string() }),
30
32
  ]);
31
- export type LiveServerMessage = z.infer<typeof LiveServerMessageSchema>;
@@ -0,0 +1,40 @@
1
+ /** The server's live wire messages; validation equivalence is checked against live-protocol. */
2
+ export type LiveServerMessage =
3
+ | { t: "ready"; id: string; cursor: string }
4
+ | { t: "event"; id: string; cursor: string; data: unknown }
5
+ | { t: "progress"; cursor: string }
6
+ | { t: "resync"; id: string; cursor: string }
7
+ | { t: "revoked"; id: string; code: "not_found" | "access_denied" }
8
+ | { t: "error"; code: string; message: string };
9
+
10
+ /** Strip undeclared keys just like the server-message schema. */
11
+ export const parseLiveServerMessage = (value: unknown): LiveServerMessage | null => {
12
+ if (typeof value !== "object" || value === null || Array.isArray(value) || !("t" in value)) return null;
13
+ switch (value.t) {
14
+ case "ready":
15
+ case "resync":
16
+ case "event": {
17
+ if (!("id" in value) || typeof value.id !== "string" || !("cursor" in value) || typeof value.cursor !== "string") return null;
18
+ if (value.t === "event") {
19
+ // Zod v4 requires the key even though its value is unknown (and may be undefined).
20
+ return "data" in value ? { t: value.t, id: value.id, cursor: value.cursor, data: value.data } : null;
21
+ }
22
+ return { t: value.t, id: value.id, cursor: value.cursor };
23
+ }
24
+ case "progress":
25
+ return "cursor" in value && typeof value.cursor === "string" ? { t: value.t, cursor: value.cursor } : null;
26
+ case "revoked":
27
+ return "id" in value &&
28
+ typeof value.id === "string" &&
29
+ "code" in value &&
30
+ (value.code === "not_found" || value.code === "access_denied")
31
+ ? { t: value.t, id: value.id, code: value.code }
32
+ : null;
33
+ case "error":
34
+ return "code" in value && typeof value.code === "string" && "message" in value && typeof value.message === "string"
35
+ ? { t: value.t, code: value.code, message: value.message }
36
+ : null;
37
+ default:
38
+ return null;
39
+ }
40
+ };
@@ -0,0 +1,88 @@
1
+ import { CursorMismatchError, RetentionGapError, type TopicEvent } from "@k2b/sync";
2
+ import type { LiveEnvelope, LiveTopic } from "./live-engine";
3
+
4
+ /** Publishes an application's real outbox without starting its application. */
5
+ export { startLiveOutbox } from "./live";
6
+ export {
7
+ /** Creates the real delivery engine for application channel tests. */
8
+ createLiveEngine,
9
+ /** Platform bounds; restore any intervals shortened by a test. */
10
+ LIVE_LIMITS,
11
+ /** The delivery engine used by channel tests. */
12
+ type LiveEngine,
13
+ /** One application's update, access change, or resync hint. */
14
+ type LiveEnvelope,
15
+ /** The topic operations consumed by the delivery engine. */
16
+ type LiveTopic,
17
+ } from "./live-engine";
18
+
19
+ /** A memory topic with Sync cursor and retention rules, plus publish and trim controls. */
20
+ export const createMemoryLiveTopic = () => {
21
+ const name = crypto.randomUUID().replaceAll("-", "");
22
+ let sequence = 0;
23
+ let first = 1;
24
+ const events: TopicEvent<LiveEnvelope>[] = [];
25
+ const wakes = new Set<() => void>();
26
+ const cursorAt = (seq: number) => `s6t.${name}.${seq}`;
27
+ const cursorSequence = (cursor: string) => {
28
+ const match = /^s6t\.([a-z0-9]+)\.(\d+)$/.exec(cursor);
29
+ if (!match || match[1] !== name) throw new CursorMismatchError("The cursor belongs to another topic");
30
+ return Number(match[2]);
31
+ };
32
+ const checkGap = (position: number) => {
33
+ if (position + 1 < first) throw new RetentionGapError(cursorAt(position + 1), cursorAt(first), cursorAt(first - 1));
34
+ };
35
+ return {
36
+ cursorAt,
37
+ cursorSequence,
38
+ head: async () => cursorAt(sequence),
39
+ publish: (data: LiveEnvelope) => {
40
+ events.push({
41
+ data,
42
+ eventId: String(++sequence),
43
+ cursor: cursorAt(sequence),
44
+ sequence,
45
+ tenantId: "default",
46
+ publishedAt: new Date(),
47
+ });
48
+ for (const wake of wakes) wake();
49
+ },
50
+ trim: (count: number) => {
51
+ events.splice(0, Math.max(0, count));
52
+ first = events[0]?.sequence ?? sequence + 1;
53
+ },
54
+ replay: (options: { after?: string; until?: string } = {}): AsyncIterable<TopicEvent<LiveEnvelope>> => {
55
+ const after = cursorSequence(options.after ?? cursorAt(0));
56
+ const until = options.until ? cursorSequence(options.until) : sequence;
57
+ checkGap(after);
58
+ const batch = events.filter((event) => event.sequence > after && event.sequence <= until);
59
+ return (async function* () {
60
+ yield* batch;
61
+ })();
62
+ },
63
+ follow: (options: { after?: string; signal?: AbortSignal } = {}): AsyncIterable<TopicEvent<LiveEnvelope>> => {
64
+ let position = cursorSequence(options.after ?? cursorAt(0));
65
+ return (async function* () {
66
+ while (!options.signal?.aborted) {
67
+ checkGap(position);
68
+ for (const event of events.filter((event) => event.sequence > position)) {
69
+ position = event.sequence;
70
+ yield event;
71
+ }
72
+ // Publication during a yielded batch must not be lost before we wait.
73
+ if (sequence > position) continue;
74
+ await new Promise<void>((resolve) => {
75
+ const wake = () => {
76
+ wakes.delete(wake);
77
+ options.signal?.removeEventListener("abort", wake);
78
+ resolve();
79
+ };
80
+ wakes.add(wake);
81
+ options.signal?.addEventListener("abort", wake, { once: true });
82
+ if (options.signal?.aborted) wake();
83
+ });
84
+ }
85
+ })();
86
+ },
87
+ } satisfies LiveTopic & { publish(data: LiveEnvelope): void; trim(count: number): void };
88
+ };
@@ -1,4 +1,5 @@
1
1
  export { buildMetadata } from "../_internal/build-metadata";
2
+ export { loadPendingApprovals } from "../api/approvals-loader";
2
3
  export { expectUserBackedActor, getUserBackedActor, isAppSessionActor, userFromActor } from "./actor";
3
4
  export type { ApiErrorBody, ApiErrorResponse, ApiErrorStatus } from "./api";
4
5
  export { api, respond, respondMessage } from "./api";
@@ -39,6 +40,7 @@ export {
39
40
  validator,
40
41
  } from "./middleware";
41
42
  export { RateLimitError, type RateLimiter, type RateLimiterConfig, type RateLimitResult, ratelimit } from "./ratelimit";
43
+ export { REQUEST_IDLE_TIMEOUT_MAX_SECONDS, REQUEST_IDLE_TIMEOUT_SECONDS, setRequestTimeout } from "./request-timeout";
42
44
  export type {
43
45
  AccessEntry,
44
46
  AccessPrincipalColumns,
@@ -1,7 +1,15 @@
1
1
  import type { Context } from "hono";
2
2
  import { createMiddleware } from "hono/factory";
3
3
  import { measureServerPhase } from "../../_internal/server-timing";
4
- import type { MessageResponse, Role, RoleOrSpecial, User, UserProfile, UserProvider } from "../../contracts/shared";
4
+ import {
5
+ AI_INFERENCE_SCOPE,
6
+ type MessageResponse,
7
+ type Role,
8
+ type RoleOrSpecial,
9
+ type User,
10
+ type UserProfile,
11
+ type UserProvider,
12
+ } from "../../contracts/shared";
5
13
  import { isAccountExpired } from "../../services/account-model";
6
14
  import { isReservedWorkloadApiCredential } from "../../services/identity/workload-auth";
7
15
  import { oauthTokens } from "../../services/oauth-tokens";
@@ -161,6 +169,8 @@ const loadAuthenticatedActorUncached = async (
161
169
  // App workload keys enter only through Core's dedicated broker/authority
162
170
  // handlers, which authenticate their exact app binding directly.
163
171
  if (isReservedWorkloadApiCredential(authResult)) return { token: null, user: null, actor: null };
172
+ // Restrict by rejection: a forgotten route check makes a models-only key work nowhere, never grants full access.
173
+ if (authResult.credential.scopes.includes(AI_INFERENCE_SCOPE)) return { token: null, user: null, actor: null };
164
174
  if (authResult.delegatedUser && isAccountExpired(authResult.delegatedUser.accountExpires)) {
165
175
  return { token: null, user: null, actor: null };
166
176
  }
@@ -0,0 +1,22 @@
1
+ import type { Context } from "hono";
2
+
3
+ /** Idle timeout of every Cloud app server and the gateway: Bun's default, kept explicit. */
4
+ export const REQUEST_IDLE_TIMEOUT_SECONDS = 10;
5
+ /**
6
+ * Longest a request may stay silent; the gateway waits this long for an app's response headers.
7
+ * Stays below Bun's 255-second limit with headroom for the gateway's coarse upstream timer.
8
+ */
9
+ export const REQUEST_IDLE_TIMEOUT_MAX_SECONDS = 240;
10
+
11
+ /**
12
+ * Restart this request's bounded idle timer while waiting to answer.
13
+ * Bun retains the raised timeout on the keep-alive socket after the response,
14
+ * so restore REQUEST_IDLE_TIMEOUT_SECONDS once the handler starts answering.
15
+ */
16
+ export const setRequestTimeout = (c: Context, seconds: number): void => {
17
+ if (!Number.isInteger(seconds) || seconds < 1 || seconds > REQUEST_IDLE_TIMEOUT_MAX_SECONDS)
18
+ throw new RangeError(`Request idle timeout must be an integer between 1 and ${REQUEST_IDLE_TIMEOUT_MAX_SECONDS} seconds`);
19
+ const server: unknown = c.env;
20
+ if (typeof server === "object" && server !== null && "timeout" in server && typeof server.timeout === "function")
21
+ server.timeout(c.req.raw, seconds);
22
+ };
@@ -438,12 +438,13 @@ const uuidArrayWithNulls = (values: readonly (string | null)[]): string => `{${v
438
438
  * channel's `authorize`.
439
439
  */
440
440
  export const getEffectivePermissions = async (params: {
441
- accessIds: string[];
441
+ /** Grant IDs, or a subquery selecting one uuid column from the resource's junction table. */
442
+ accessIds: string[] | SQLQuery;
442
443
  subjects: readonly AccessSubject[];
443
444
  }): Promise<PermissionLevel[]> => {
444
445
  const { accessIds, subjects } = params;
445
446
  if (subjects.length === 0) return [];
446
- if (accessIds.length === 0) return subjects.map(() => "none");
447
+ if (Array.isArray(accessIds) && accessIds.length === 0) return subjects.map(() => "none");
447
448
  const userIds = subjects.map((subject) => (subject.type === "user" ? subject.userId : null));
448
449
  const serviceAccountIds = subjects.map((subject) => (subject.type === "service_account" ? subject.serviceAccountId : null));
449
450
  // The tiers of buildAccessPrincipalTierConditions, evaluated per row of `s`; every subject here is authenticated.
@@ -451,7 +452,7 @@ export const getEffectivePermissions = async (params: {
451
452
  SELECT s.position::int AS position, (
452
453
  SELECT a.permission
453
454
  FROM auth.access a
454
- WHERE a.id = ANY(${toPgUuidArray(accessIds)}::uuid[])
455
+ WHERE ${Array.isArray(accessIds) ? sql`a.id = ANY(${toPgUuidArray(accessIds)}::uuid[])` : sql`a.id IN (${accessIds})`}
455
456
  AND (
456
457
  a.service_account_id = s.service_account_id
457
458
  OR a.user_id = s.user_id
@@ -3,8 +3,11 @@ import { lazySync } from "../_internal/process-sync";
3
3
  import { env } from "../config/env";
4
4
  import { type AccountCategory, accountCategory } from "../contracts/account-categories";
5
5
  import {
6
+ APP_APPROVAL_FEATURES,
6
7
  APP_APPROVAL_PATH,
7
8
  APP_APPROVAL_PROTOCOL,
9
+ type AppActionSummary,
10
+ type AppActionView,
8
11
  type AppDevicePublicKey,
9
12
  AppDevicePublicKeySchema,
10
13
  type AppDeviceRequest,
@@ -147,8 +150,28 @@ export type AppLoginDecisionHints = {
147
150
  /** Settles on the first hint for this request, or when `signal` aborts. */
148
151
  wait(requestId: string, signal: AbortSignal): Promise<void>;
149
152
  };
153
+ /** Open Action approvals of a device's account, as the device shows and decides them. */
154
+ export type AppDeviceActions = {
155
+ inbox(input: { userId: string; issuer: string }): Promise<{ actions: AppActionSummary[]; moreActions: number }>;
156
+ view(input: { userId: string; issuer: string; actionId: string }): Promise<AppActionView | null>;
157
+ /** True when this decision applied; false when the request is gone, decided, expired, or its view changed. */
158
+ decide(input: {
159
+ userId: string;
160
+ deviceId: string;
161
+ issuer: string;
162
+ actionId: string;
163
+ digest: string;
164
+ approved: boolean;
165
+ }): Promise<boolean>;
166
+ };
167
+ // Bundle Action wake-ups for 30 seconds while the previously woken request is still open.
168
+ // Cloud Login gives Actions a separate 30-per-token budget in five minutes, preserving the full sign-in budget.
169
+ const ACTION_WAKE_SECONDS = 30;
150
170
  /** Best-effort sign-in wake-ups through the trusted authenticator; returns the HTTP status. */
151
- export type AppLoginPushSender = (appOrigin: string, body: { token: string; cloudOrigin: string; requestRef: string }) => Promise<number>;
171
+ export type AppLoginPushSender = (
172
+ appOrigin: string,
173
+ body: { token: string; cloudOrigin: string; requestRef: string; kind?: "action" },
174
+ ) => Promise<number>;
152
175
  export const fetchPushSender: AppLoginPushSender = async (appOrigin, body) => {
153
176
  const response = await fetch(`${appOrigin}/push/notify`, {
154
177
  method: "POST",
@@ -265,6 +288,36 @@ export const createAppApprovalService = (
265
288
  return {
266
289
  config,
267
290
  cleanup,
291
+ wakeForAction: async (approvalId: string): Promise<void> => {
292
+ try {
293
+ const cfg = await config(false);
294
+ if (!cfg.enabled || !cfg.appOrigin) return;
295
+ const rows = await db<{ push_token: string; user_id: string }[]>`
296
+ UPDATE auth.app_devices d SET action_wake_at=now(), action_wake_ref=${approvalId}::uuid
297
+ WHERE d.id IN (
298
+ SELECT device.id FROM auth.app_devices device JOIN approvals.requests r ON r.approver_user_id=device.user_id
299
+ WHERE r.id=${approvalId}::uuid AND r.status='pending' AND r.expires_at > now()
300
+ AND EXISTS (SELECT 1 FROM ai.pending_actions p JOIN ai.turns t ON t.id=p.turn_id
301
+ WHERE p.approval_id=r.id AND p.status='pending' AND t.run_config->>'signedInSession' IS NULL)
302
+ AND device.issuer=${cfg.issuer} AND device.revoked_at IS NULL AND device.push_token IS NOT NULL
303
+ AND device.actions_at IS NOT NULL
304
+ LIMIT ${limits.devicesPerAccount}
305
+ ) AND (d.action_wake_at IS NULL OR d.action_wake_at <= now() - ${ACTION_WAKE_SECONDS} * interval '1 second'
306
+ OR NOT EXISTS (SELECT 1 FROM approvals.requests w
307
+ WHERE w.id=d.action_wake_ref AND w.status='pending' AND w.expires_at > now()))
308
+ RETURNING d.push_token,d.user_id`;
309
+ const tokens = new Map(rows.map((row) => [row.push_token, row.user_id]));
310
+ await Promise.all(
311
+ Array.from(tokens, async ([token, userId]) => {
312
+ const status = await push(cfg.appOrigin, { token, cloudOrigin: cfg.issuer, requestRef: approvalId, kind: "action" });
313
+ if (status === 410)
314
+ await db`UPDATE auth.app_devices SET push_token=NULL WHERE issuer=${cfg.issuer} AND user_id=${userId}::uuid AND push_token=${token}`;
315
+ }),
316
+ );
317
+ } catch (error) {
318
+ log.warn("App action push wake-up failed", { error: error instanceof Error ? error.name : "UnknownError" });
319
+ }
320
+ },
268
321
  maintain: async (notify: (notice: AppDeviceEnrollmentNotice) => Promise<unknown>, signal?: AbortSignal) => {
269
322
  await cleanup();
270
323
  const rows = await db<(DeviceRow & { has_email: boolean })[]>`
@@ -289,6 +342,7 @@ export const createAppApprovalService = (
289
342
  api: cfg.issuer + APP_APPROVAL_PATH,
290
343
  appOrigin: cfg.appOrigin,
291
344
  algorithm: "ES256",
345
+ features: [...APP_APPROVAL_FEATURES],
292
346
  limits,
293
347
  };
294
348
  },
@@ -478,7 +532,7 @@ export const createAppApprovalService = (
478
532
  if (owner) void wake(cfg, owner, id);
479
533
  return { requestId: id, browserSecret, comparison: code, expiresAt: iso(expiresAt), pollAfterSeconds: limits.pollSeconds };
480
534
  },
481
- deviceCommand: async (request: AppDeviceRequest) => {
535
+ deviceCommand: async (request: AppDeviceRequest, actions: AppDeviceActions) => {
482
536
  const cfg = await config();
483
537
  const { proof, signature } = request;
484
538
  const now = Math.floor(Date.now() / 1000);
@@ -521,23 +575,27 @@ export const createAppApprovalService = (
521
575
  await record(tx, "device.revoke", device.user_id, device.id);
522
576
  return { state: "revoked" as const };
523
577
  }
524
- if (command.operation === "pending") {
578
+ if (command.operation === "action" || command.operation === "act")
579
+ return { operation: command.operation, userId: device.user_id, deviceId: device.id };
580
+ if (command.operation === "pending" || command.operation === "inbox") {
525
581
  const user = await eligible(tx, device.user_id);
526
582
  const rows = await tx<
527
583
  LoginRow[]
528
584
  >`SELECT * FROM auth.app_logins WHERE issuer=${cfg.issuer} AND user_id=${device.user_id}::uuid AND state='pending' AND expires_at>now() AND auth_epoch=${user.auth_epoch} AND category=${accountCategory(user)} ORDER BY created_at LIMIT ${limits.pendingPerAccount}`;
529
- return {
530
- requests: rows.map(
531
- (row): AppPendingLogin => ({
532
- requestId: row.id,
533
- challenge: row.challenge,
534
- comparison: row.comparison,
535
- createdAt: iso(row.created_at),
536
- expiresAt: iso(row.expires_at),
537
- }),
538
- ),
539
- pollAfterSeconds: limits.pollSeconds,
540
- };
585
+ const requests = rows.map(
586
+ (row): AppPendingLogin => ({
587
+ requestId: row.id,
588
+ challenge: row.challenge,
589
+ comparison: row.comparison,
590
+ createdAt: iso(row.created_at),
591
+ expiresAt: iso(row.expires_at),
592
+ }),
593
+ );
594
+ if (command.operation === "inbox") {
595
+ await tx`UPDATE auth.app_devices SET actions_at=now() WHERE id=${device.id}::uuid`;
596
+ return { operation: "inbox" as const, userId: device.user_id, logins: requests };
597
+ }
598
+ return { requests, pollAfterSeconds: limits.pollSeconds };
541
599
  }
542
600
  const [row] = await tx<
543
601
  LoginRow[]
@@ -558,6 +616,36 @@ export const createAppApprovalService = (
558
616
  await record(tx, `login.${command.decision}`, device.user_id, device.id, { requestId: row.id });
559
617
  return { state };
560
618
  });
619
+ if (outcome.operation !== undefined) {
620
+ const command = proof.command;
621
+ const input = { userId: outcome.userId, issuer: cfg.issuer };
622
+ if (outcome.operation === "inbox") {
623
+ const inbox = { logins: outcome.logins, ...(await actions.inbox(input)), pollAfterSeconds: limits.pollSeconds };
624
+ // Multibyte summaries share the unchanged response budget with pending sign-ins.
625
+ while (inbox.actions.length && new TextEncoder().encode(JSON.stringify(inbox)).length > limits.bodyBytes) {
626
+ inbox.actions.pop();
627
+ inbox.moreActions++;
628
+ }
629
+ return inbox;
630
+ }
631
+ if (command.operation === "action") {
632
+ const view = await actions.view({ ...input, actionId: command.actionId });
633
+ return view ?? reject("CONFLICT", 409);
634
+ }
635
+ if (command.operation === "act") {
636
+ const approved = command.decision === "approve";
637
+ const applied = await actions.decide({
638
+ ...input,
639
+ deviceId: outcome.deviceId,
640
+ actionId: command.actionId,
641
+ digest: command.digest,
642
+ approved,
643
+ });
644
+ if (!applied) return reject("CONFLICT", 409);
645
+ return { state: approved ? ("approved" as const) : ("denied" as const) };
646
+ }
647
+ return reject("INVALID_REQUEST", 400);
648
+ }
561
649
  // Only after commit: the waiting browser re-reads Postgres, so a hint never grants anything.
562
650
  if (proof.command.operation === "decide") hints.publish(proof.command.requestId);
563
651
  return outcome;
@@ -0,0 +1,15 @@
1
+ import { z } from "zod";
2
+
3
+ const CursorSchema = z.object({ createdAt: z.iso.datetime(), id: z.uuid() }).strict();
4
+ type ApprovalCursor = z.infer<typeof CursorSchema>;
5
+
6
+ export const encodeApprovalCursor = (cursor: ApprovalCursor): string => Buffer.from(JSON.stringify(cursor)).toString("base64url");
7
+ export const decodeApprovalCursor = (cursor: string): ApprovalCursor | null => {
8
+ try {
9
+ if (!/^[A-Za-z0-9_-]{1,200}$/.test(cursor)) return null;
10
+ const parsed = CursorSchema.safeParse(JSON.parse(Buffer.from(cursor, "base64url").toString()));
11
+ return parsed.success ? parsed.data : null;
12
+ } catch {
13
+ return null;
14
+ }
15
+ };