@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.
- package/package.json +7 -4
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/capabilities.ts +2 -2
- package/src/_internal/define-app.ts +17 -5
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/ai/app-skills.ts +1 -7
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- 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 +64 -39
- 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/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +9 -1
- package/src/ai/chat/message-utils.ts +7 -0
- package/src/ai/chat/messages.ts +34 -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 +67 -12
- package/src/ai/chat/tool-names.ts +18 -2
- package/src/ai/chat/turn-layout.ts +8 -0
- package/src/ai/chat/turn-view.tsx +111 -32
- 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/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/client-tool-results.ts +9 -0
- package/src/ai/code-runtime-tools.ts +50 -17
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/default-tool-contracts.ts +334 -91
- package/src/ai/default-tools.ts +87 -11
- package/src/ai/executor.ts +343 -45
- package/src/ai/fetch-file-tool.ts +41 -4
- 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/firecrawl-tools.ts +87 -4
- package/src/ai/html-pdf-tool.ts +3 -2
- package/src/ai/http.ts +37 -12
- package/src/ai/index.ts +98 -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 +120 -2
- 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 +28 -3
- package/src/ai/provider.ts +16 -4
- package/src/ai/request-context.ts +33 -0
- package/src/ai/routes.ts +99 -49
- package/src/ai/runtime.ts +90 -15
- 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 +91 -1
- package/src/ai/system-prompt.ts +69 -10
- package/src/ai/timeline.ts +3 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tools.ts +94 -3
- package/src/ai/types.ts +48 -0
- package/src/ai/view-block.ts +941 -0
- package/src/ai/vision-tool.ts +2 -1
- package/src/ai/web-provenance.ts +103 -0
- 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/capabilities.ts +3 -2
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +12 -3
- package/src/api/widgets.ts +36 -5
- 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/CloudResourceSearch.tsx +1 -1
- package/src/browser/app-approval.ts +32 -0
- package/src/browser/choose-files.tsx +26 -13
- package/src/browser/command-shortcuts.ts +21 -6
- 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/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +7 -5
- package/src/capabilities/executions.ts +3 -0
- package/src/cli/account.ts +21 -2
- package/src/cli/commands.ts +24 -2
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/app-approval.ts +120 -0
- package/src/contracts/app.ts +34 -2
- 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/registry.ts +9 -1
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +47 -2
- package/src/desktop/solid.tsx +6 -7
- 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 +3 -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/server/widget-request.ts +27 -0
- 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 +241 -0
- package/src/services/approvals/list.ts +120 -0
- package/src/services/approvals/migrate.ts +41 -0
- 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/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +46 -7
- 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/AdminLayout.tsx +4 -7
- package/src/ssr/AppBadge.tsx +4 -4
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +4 -4
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/app-badges.ts +50 -6
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +265 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- 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/effects.css +21 -4
- package/src/styles/global.css +39 -3
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
|
@@ -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,
|
|
@@ -94,3 +96,4 @@ export {
|
|
|
94
96
|
updateAccess,
|
|
95
97
|
} from "./services";
|
|
96
98
|
export { getDateConfig, getTimeZone, TIMEZONE_COOKIE, time } from "./time";
|
|
99
|
+
export { getWidgetRequest } from "./widget-request";
|
|
@@ -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
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { type DashboardWidgetSize, fitWidgetSize, resolveWidgetSizes, type WidgetRequest } from "../contracts/widgets";
|
|
3
|
+
|
|
4
|
+
/** The query parameter Core uses to ask a widget handler for one size. */
|
|
5
|
+
export const WIDGET_SIZE_PARAM = "size";
|
|
6
|
+
|
|
7
|
+
const requests = new WeakMap<Request, WidgetRequest>();
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Records what the dashboard asked a declared widget for, already fitted to the declaration. Only the framework's
|
|
11
|
+
* internal widget route calls this, before it runs the handler.
|
|
12
|
+
*/
|
|
13
|
+
export const setWidgetRequest = (
|
|
14
|
+
c: Context,
|
|
15
|
+
declaration: { sizes?: readonly DashboardWidgetSize[]; defaultSize?: DashboardWidgetSize },
|
|
16
|
+
): WidgetRequest => {
|
|
17
|
+
const request = { size: fitWidgetSize(c.req.query(WIDGET_SIZE_PARAM), resolveWidgetSizes(declaration)) };
|
|
18
|
+
requests.set(c.req.raw, request);
|
|
19
|
+
return request;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What the dashboard asks this widget handler for. `size` is always one of the sizes the widget declares; return the
|
|
24
|
+
* content that fits it, for example only the one number in `small`. Outside a dashboard invocation, such as a request
|
|
25
|
+
* to the handler's own public route, the request is `large`, the height every widget had before sizes existed.
|
|
26
|
+
*/
|
|
27
|
+
export const getWidgetRequest = (c: Context): WidgetRequest => requests.get(c.req.raw) ?? { size: "large" };
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
APP_APPROVAL_LIMITS,
|
|
3
|
+
type AppActionSummary,
|
|
4
|
+
type AppActionView,
|
|
5
|
+
appActionDigest,
|
|
6
|
+
APP_ACTION_LIMITS as limits,
|
|
7
|
+
} from "../../contracts/app-approval";
|
|
8
|
+
import { CapabilityActionReviewSchema } from "../../contracts/capabilities";
|
|
9
|
+
import { DEFAULT_LOCALE } from "../../server/locale";
|
|
10
|
+
import type { PendingApprovalRow } from "./list";
|
|
11
|
+
|
|
12
|
+
const plain = (value: string): string =>
|
|
13
|
+
value
|
|
14
|
+
.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, " ")
|
|
15
|
+
.replace(/\p{Cf}/gu, "")
|
|
16
|
+
.replace(/\s+/gu, " ")
|
|
17
|
+
.trim();
|
|
18
|
+
const cut = (value: string, limit: number): string => {
|
|
19
|
+
const points = Array.from(value);
|
|
20
|
+
if (limit < 1) return "";
|
|
21
|
+
return points.length > limit ? `${points.slice(0, limit - 1).join("")}…` : value;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** A deterministic, plain-text snapshot; the digest binds the device's choice to everything it shows. */
|
|
25
|
+
export const appActionView = async (
|
|
26
|
+
row: PendingApprovalRow,
|
|
27
|
+
options: { issuer: string; appName: (appId: string, locale: string) => string },
|
|
28
|
+
): Promise<AppActionView> => {
|
|
29
|
+
let truncated = false;
|
|
30
|
+
const text = (value: string, limit: number) => {
|
|
31
|
+
const cleaned = plain(value);
|
|
32
|
+
const result = cut(cleaned, limit);
|
|
33
|
+
if (result !== cleaned) truncated = true;
|
|
34
|
+
return result;
|
|
35
|
+
};
|
|
36
|
+
const locale = row.runConfig?.kind === "compact" ? DEFAULT_LOCALE : (row.runConfig?.locale ?? DEFAULT_LOCALE);
|
|
37
|
+
const appName = plain(options.appName(row.appId, locale));
|
|
38
|
+
const app = { id: text(row.appId, limits.nameChars), name: text(appName, limits.nameChars) };
|
|
39
|
+
const requester = {
|
|
40
|
+
app: { id: text(row.requesterAppId, limits.nameChars), name: text(options.appName(row.requesterAppId, locale), limits.nameChars) },
|
|
41
|
+
// The snapshot, so renaming the chat does not change what the device signs.
|
|
42
|
+
title: text(row.requesterTitle ?? "", limits.titleChars) || null,
|
|
43
|
+
};
|
|
44
|
+
const lines = row.sentence.split(/\r?\n/).map(plain).filter(Boolean);
|
|
45
|
+
const first = lines[0] ?? plain(row.action);
|
|
46
|
+
const prefix = `${appName}: `;
|
|
47
|
+
const sentence = first.startsWith(prefix) && first.length > prefix.length ? first.slice(prefix.length) : first;
|
|
48
|
+
const review = CapabilityActionReviewSchema.safeParse(row.details);
|
|
49
|
+
let message: string | null;
|
|
50
|
+
const details: AppActionView["details"] = [];
|
|
51
|
+
const detail = (label: string, value: string, format?: "date" | "date-time") => {
|
|
52
|
+
if (details.length >= limits.details) {
|
|
53
|
+
truncated = true;
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const cleanedLabel = text(label, limits.labelChars);
|
|
57
|
+
if (!cleanedLabel) return;
|
|
58
|
+
details.push({ label: cleanedLabel, value: text(value, limits.valueChars), ...(format ? { format } : {}) });
|
|
59
|
+
};
|
|
60
|
+
const messages: string[] = [];
|
|
61
|
+
const reviewLines = new Set<string>();
|
|
62
|
+
if (review.success) {
|
|
63
|
+
const cleaned = plain(review.data.message);
|
|
64
|
+
if (cleaned !== sentence) messages.push(cleaned);
|
|
65
|
+
reviewLines.add(cleaned);
|
|
66
|
+
for (const line of review.data.message.split(/\r?\n/)) reviewLines.add(plain(line));
|
|
67
|
+
for (const field of review.data.details ?? [])
|
|
68
|
+
detail(field.label, field.value, field.format === "date" || field.format === "date-time" ? field.format : undefined);
|
|
69
|
+
}
|
|
70
|
+
for (const line of lines.slice(1)) {
|
|
71
|
+
if (reviewLines.has(line)) continue;
|
|
72
|
+
const match = /^([^:]{1,40}):\s+(.+)$/.exec(line);
|
|
73
|
+
if (match) detail(match[1]!, match[2]!);
|
|
74
|
+
else messages.push(line);
|
|
75
|
+
}
|
|
76
|
+
message = text(messages.join(" "), limits.messageChars) || null;
|
|
77
|
+
const boundedSentence = text(sentence, limits.sentenceChars);
|
|
78
|
+
const view: AppActionView = {
|
|
79
|
+
actionId: row.id,
|
|
80
|
+
app,
|
|
81
|
+
requester,
|
|
82
|
+
assurance: row.assurance,
|
|
83
|
+
sentence: boundedSentence,
|
|
84
|
+
message,
|
|
85
|
+
details,
|
|
86
|
+
truncated,
|
|
87
|
+
argsDigest: row.argsDigest,
|
|
88
|
+
createdAt: row.createdAt.toISOString(),
|
|
89
|
+
expiresAt: row.expiresAt.toISOString(),
|
|
90
|
+
// Reserve the digest's bytes while bounding the final response.
|
|
91
|
+
digest: "A".repeat(43),
|
|
92
|
+
};
|
|
93
|
+
const bytes = () => new TextEncoder().encode(JSON.stringify(view)).length;
|
|
94
|
+
while (bytes() > APP_APPROVAL_LIMITS.bodyBytes) {
|
|
95
|
+
view.truncated = true;
|
|
96
|
+
if (view.details.length) view.details.pop();
|
|
97
|
+
else if (view.message) view.message = cut(view.message, Array.from(view.message).length - 1) || null;
|
|
98
|
+
else break;
|
|
99
|
+
}
|
|
100
|
+
view.digest = await appActionDigest(options.issuer, view);
|
|
101
|
+
return view;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const appActionSummary = (view: AppActionView): AppActionSummary => ({
|
|
105
|
+
actionId: view.actionId,
|
|
106
|
+
app: view.app,
|
|
107
|
+
requester: view.requester,
|
|
108
|
+
assurance: view.assurance,
|
|
109
|
+
sentence: cut(view.sentence, limits.summaryChars),
|
|
110
|
+
createdAt: view.createdAt,
|
|
111
|
+
expiresAt: view.expiresAt,
|
|
112
|
+
});
|