@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.
- package/package.json +6 -4
- package/src/_internal/define-app.ts +3 -1
- package/src/ai/app-skills.ts +1 -7
- package/src/ai/audio-tool.ts +2 -1
- package/src/ai/browser.ts +109 -0
- package/src/ai/capabilities.ts +6 -2
- package/src/ai/chart-block.ts +431 -81
- package/src/ai/chat/action-sentences.ts +13 -7
- package/src/ai/chat/blocks.tsx +11 -7
- package/src/ai/chat/builtin-tools.tsx +9 -7
- package/src/ai/chat/chart-block.tsx +218 -44
- package/src/ai/chat/explorer-block.tsx +276 -0
- package/src/ai/chat/file-tools.tsx +40 -1
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/message-utils.ts +7 -0
- package/src/ai/chat/messages.ts +16 -0
- package/src/ai/chat/presentation.tsx +13 -3
- package/src/ai/chat/reference-text.tsx +34 -0
- package/src/ai/chat/tool-groups.ts +1 -0
- package/src/ai/chat/tool-names.ts +14 -0
- package/src/ai/chat/turn-view.tsx +17 -7
- package/src/ai/chat/view-block.browser-harness.tsx +35 -0
- package/src/ai/chat/view-block.tsx +417 -0
- package/src/ai/chat/visual-tools.tsx +32 -53
- package/src/ai/client-tool-results.ts +9 -0
- package/src/ai/default-tool-contracts.ts +334 -91
- package/src/ai/default-tools.ts +87 -11
- package/src/ai/executor.ts +173 -31
- package/src/ai/file-error.ts +2 -0
- package/src/ai/file-tools.ts +27 -7
- package/src/ai/files-store.ts +60 -0
- package/src/ai/html-pdf-tool.ts +3 -2
- package/src/ai/http.ts +37 -12
- package/src/ai/index.ts +90 -0
- package/src/ai/local-tool-contracts.ts +244 -0
- package/src/ai/malformed-tool-call-repair.ts +144 -0
- package/src/ai/markdown-pdf-tool.ts +2 -1
- package/src/ai/migrate.ts +7 -0
- package/src/ai/openai-api-messages.ts +53 -0
- package/src/ai/openai-api-slots.ts +40 -0
- package/src/ai/openai-api.ts +383 -0
- package/src/ai/protocol.ts +6 -2
- package/src/ai/provider.ts +16 -4
- package/src/ai/request-context.ts +33 -0
- package/src/ai/routes.ts +96 -57
- package/src/ai/runtime.ts +48 -12
- package/src/ai/settings.ts +5 -0
- package/src/ai/skill-catalog.ts +20 -5
- package/src/ai/skill-format.ts +17 -7
- package/src/ai/skill-tool.ts +15 -5
- package/src/ai/skills-routes.ts +20 -6
- package/src/ai/skills.ts +26 -7
- package/src/ai/store.ts +25 -0
- package/src/ai/system-prompt.ts +69 -10
- package/src/ai/timeline.ts +3 -1
- package/src/ai/tools.ts +3 -0
- package/src/ai/types.ts +9 -0
- package/src/ai/view-block.ts +941 -0
- package/src/ai/vision-tool.ts +2 -1
- package/src/api/admin-identity.ts +6 -1
- package/src/api/app-approval-actions.ts +36 -0
- package/src/api/app-approval.ts +10 -2
- package/src/api/approvals-loader.ts +15 -0
- package/src/api/approvals.ts +120 -0
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +12 -3
- package/src/approvals/Approvals.browser-harness.tsx +16 -0
- package/src/approvals/Approvals.tsx +621 -0
- package/src/approvals/ApprovalsNavStatus.tsx +20 -0
- package/src/approvals/messages.ts +95 -0
- package/src/approvals/ui.ts +8 -0
- package/src/browser/app-approval.ts +32 -0
- package/src/browser/choose-files.tsx +26 -13
- package/src/browser/commands.ts +13 -4
- package/src/browser/file-provider-error.ts +11 -0
- package/src/browser/file-providers.ts +3 -12
- package/src/browser/files.tsx +1 -2
- package/src/browser/live-connection.ts +2 -3
- package/src/browser/provider-list.ts +2 -1
- package/src/browser/save-files.tsx +13 -2
- package/src/cli/account.ts +21 -2
- package/src/cli/commands.ts +4 -2
- package/src/contracts/app-approval.ts +120 -0
- package/src/contracts/approval-paths.ts +4 -0
- package/src/contracts/approvals.ts +81 -0
- package/src/contracts/command-path.ts +17 -0
- package/src/contracts/commands.ts +5 -15
- package/src/contracts/shared.ts +5 -0
- package/src/events/live-protocol.ts +2 -1
- package/src/events/live-server-message.ts +40 -0
- package/src/events/testing.ts +88 -0
- package/src/server/index.ts +2 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/request-timeout.ts +22 -0
- package/src/server/services/access.ts +4 -3
- package/src/services/app-approval.ts +103 -15
- package/src/services/approvals/cursor.ts +15 -0
- package/src/services/approvals/device.ts +112 -0
- package/src/services/approvals/index.ts +27 -11
- package/src/services/approvals/list.ts +120 -0
- package/src/services/approvals/migrate.ts +9 -1
- package/src/services/identity/models-only-auth.ts +25 -0
- package/src/services/notifications/email-frame.ts +16 -3
- package/src/services/service-account-credentials.ts +22 -10
- package/src/services/settings/core-settings.ts +27 -0
- package/src/shared/ai-platform-prompt.ts +26 -1
- package/src/shared/email-html.ts +4 -0
- package/src/shared/index.ts +6 -1
- package/src/shared/markdown/extensions/code.ts +14 -0
- package/src/shared/markdown/index.ts +4 -4
- package/src/ssr/AppBadge.tsx +4 -4
- package/src/ssr/AppLaunchpadPanel.tsx +4 -4
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/app-badges.ts +50 -6
- package/src/ssr/page-trail.browser-harness.tsx +52 -3
- package/src/ssr/rail-navigation.ts +5 -3
- package/src/ssr/runtime.ts +6 -1
- package/src/styles/approvals.css +390 -0
- 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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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>;
|
package/src/contracts/shared.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 = (
|
|
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 === "
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
+
};
|