@k2b/cloud 0.41.0 → 0.42.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 -5
- package/src/_internal/bounded-json.ts +14 -2
- package/src/_internal/capabilities.ts +87 -8
- package/src/_internal/capability-reviews.ts +6 -0
- package/src/_internal/capability-timeout.ts +71 -0
- package/src/_internal/define-app.ts +145 -89
- package/src/_internal/http-drain.ts +56 -0
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.tsx +16 -17
- package/src/access/PrincipalPicker.tsx +45 -10
- package/src/access/messages.ts +8 -0
- package/src/access/principal-label.ts +18 -0
- package/src/access/ui.ts +1 -0
- package/src/account/EntitySearch.tsx +5 -4
- package/src/ai/action-review.ts +2 -1
- package/src/ai/app-skill-store.ts +1 -1
- package/src/ai/capabilities.ts +21 -3
- package/src/ai/capability-execution.ts +14 -12
- package/src/ai/chat/action-card.ts +7 -1
- package/src/ai/chat/action-messages.ts +14 -0
- package/src/ai/chat/action-question.browser-harness.tsx +12 -0
- package/src/ai/chat/action-question.tsx +202 -22
- package/src/ai/chat/action-text.ts +1 -1
- package/src/ai/chat-tasks.ts +2 -1
- package/src/ai/client/controller.ts +150 -32
- package/src/ai/client/transport.ts +14 -2
- package/src/ai/code-capability-routes.ts +27 -23
- package/src/ai/code-runtime-tools.ts +14 -16
- package/src/ai/code-source-tools.ts +16 -18
- package/src/ai/executor.ts +56 -21
- package/src/ai/fetch-file-tool.ts +67 -76
- package/src/ai/http.ts +1 -0
- package/src/ai/migrate.ts +13 -0
- package/src/ai/open-questions.ts +4 -8
- package/src/ai/projects.ts +1 -2
- package/src/ai/protocol.ts +2 -2
- package/src/ai/recent-resources.ts +12 -5
- package/src/ai/review-content.ts +77 -0
- package/src/ai/routes.ts +69 -31
- package/src/ai/runtime.ts +1 -0
- package/src/ai/skill-tool.ts +17 -6
- package/src/ai/skills-routes.ts +72 -27
- package/src/ai/skills.ts +139 -60
- package/src/ai/store.ts +168 -33
- package/src/ai/types.ts +10 -3
- package/src/ai/usage.ts +2 -2
- package/src/api/admin-core-settings.ts +15 -2
- package/src/api/admin-geo.ts +54 -0
- package/src/api/admin-linux-identities.ts +6 -3
- package/src/api/admin-outgoing-mail.ts +29 -15
- package/src/api/approvals-loader.ts +3 -3
- package/src/api/approvals.ts +51 -0
- package/src/api/apps.ts +4 -2
- package/src/api/capabilities.ts +258 -181
- package/src/api/capability-streams.ts +21 -13
- package/src/api/cli-plugins.ts +4 -1
- package/src/api/geo-glyphs.ts +11 -0
- package/src/api/geo.ts +128 -0
- package/src/api/help.ts +12 -4
- package/src/api/index.ts +5 -0
- package/src/api/mcp.ts +46 -38
- package/src/api/me-panel-consent.ts +8 -5
- package/src/api/search.ts +21 -14
- package/src/api/sync-ops.ts +19 -13
- package/src/api/widgets.ts +42 -18
- package/src/approvals/Approvals.tsx +5 -3
- package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
- package/src/browser/CloudResourceSearch.tsx +3 -13
- package/src/browser/PanelLeaveNotice.tsx +18 -0
- package/src/browser/navigation-search.ts +78 -25
- package/src/browser/panel-leave.ts +69 -0
- package/src/browser/panel-messages.ts +12 -0
- package/src/browser/panel-slot.ts +173 -24
- package/src/browser/panel.ts +3 -0
- package/src/browser/resource-search-messages.ts +2 -0
- package/src/capabilities/client.ts +28 -1
- package/src/capabilities/executions.ts +22 -3
- package/src/capabilities/geo-client.ts +57 -0
- package/src/capabilities/response.ts +2 -1
- package/src/capabilities/review-storage.ts +24 -0
- package/src/capabilities/reviews.ts +17 -3
- package/src/capabilities/server.ts +36 -16
- package/src/cli/access.ts +15 -23
- package/src/cli/admin/linux.ts +13 -2
- package/src/cli/admin/outgoing-mail.ts +26 -4
- package/src/cli/capabilities.ts +19 -0
- package/src/config/env.ts +0 -6
- package/src/contracts/app-access.ts +48 -0
- package/src/contracts/app.ts +4 -1
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/avatar.ts +186 -0
- package/src/contracts/capabilities.ts +148 -54
- package/src/contracts/geo-admin.ts +127 -0
- package/src/contracts/geo-map.ts +19 -0
- package/src/contracts/geo-route.ts +168 -0
- package/src/contracts/geo.ts +248 -0
- package/src/contracts/index.ts +4 -0
- package/src/contracts/outgoing-mail.ts +17 -4
- package/src/contracts/registry.ts +2 -0
- package/src/events/app-access.ts +68 -0
- package/src/events/live-delivery.ts +128 -0
- package/src/events/live.ts +14 -130
- package/src/geo/CloudMap.tsx +95 -0
- package/src/geo/map-messages.ts +17 -0
- package/src/geo/ui.ts +1 -0
- package/src/index.ts +2 -0
- package/src/server/client-ip.ts +36 -0
- package/src/server/index.ts +3 -4
- package/src/server/middleware/app-access.ts +312 -0
- package/src/server/middleware/auth.ts +1 -1
- package/src/server/middleware/invocation.ts +11 -0
- package/src/server/middleware/rate-limit.ts +4 -4
- package/src/server/services/access.ts +13 -9
- package/src/server/services/index.ts +0 -2
- package/src/server/services/services.ts +0 -2
- package/src/services/accounts/posix.ts +21 -7
- package/src/services/allowed-network-fetch.ts +16 -10
- package/src/services/app-access-metrics.ts +24 -0
- package/src/services/app-access.ts +123 -0
- package/src/services/app-discovery.ts +27 -0
- package/src/services/approvals/content.ts +40 -0
- package/src/services/approvals/index.ts +14 -6
- package/src/services/approvals/list.ts +6 -11
- package/src/services/approvals/question.ts +11 -7
- package/src/services/downstream-budget.ts +87 -0
- package/src/services/geo/admin.ts +262 -0
- package/src/services/geo/bucket.ts +67 -0
- package/src/services/geo/cache.ts +57 -0
- package/src/services/geo/costs.ts +5 -0
- package/src/services/geo/errors.ts +41 -0
- package/src/services/geo/fixtures/README.md +17 -0
- package/src/services/geo/fixtures/along.json +355 -0
- package/src/services/geo/fixtures/autocomplete.json +218 -0
- package/src/services/geo/fixtures/geocode.json +129 -0
- package/src/services/geo/fixtures/places.json +355 -0
- package/src/services/geo/fixtures/reverse.json +61 -0
- package/src/services/geo/fixtures/routing.json +57 -0
- package/src/services/geo/geoapify-route.ts +93 -0
- package/src/services/geo/geoapify.ts +281 -0
- package/src/services/geo/ledger.ts +122 -0
- package/src/services/geo/map.ts +160 -0
- package/src/services/geo/migrate.ts +25 -0
- package/src/services/geo/polyline.ts +184 -0
- package/src/services/geo/route-chain.ts +145 -0
- package/src/services/geo/route-costs.ts +9 -0
- package/src/services/geo/route-geometry.ts +39 -0
- package/src/services/geo/route-plan.ts +172 -0
- package/src/services/geo/route-service.ts +433 -0
- package/src/services/geo/service.ts +300 -0
- package/src/services/geo/settings.ts +49 -0
- package/src/services/help/index.ts +16 -7
- package/src/services/help/types.ts +6 -1
- package/src/services/identity/invocation-actor.ts +20 -2
- package/src/services/identity/issue-invocation.ts +67 -0
- package/src/services/identity/key-ring.ts +5 -3
- package/src/services/identity/runtime-config.ts +23 -0
- package/src/services/index.ts +9 -0
- package/src/services/mandates/index.ts +40 -3
- package/src/services/network-security.ts +6 -3
- package/src/services/network-transport.ts +99 -0
- package/src/services/notifications/catalog.ts +3 -2
- package/src/services/notifications/runtime.ts +1 -0
- package/src/services/notifications/web-push-transport.ts +45 -38
- package/src/services/outgoing-mail/dispatcher.ts +6 -1
- package/src/services/outgoing-mail/index.ts +55 -1
- package/src/services/outgoing-mail/messages.ts +82 -3
- package/src/services/outgoing-mail/resolve-host.ts +2 -2
- package/src/services/outgoing-mail/retention.ts +13 -1
- package/src/services/outgoing-mail/store.ts +36 -6
- package/src/services/outgoing-mail/test-send.ts +34 -4
- package/src/services/outgoing-mail/transport.ts +1 -1
- package/src/services/pdf/gotenberg.ts +11 -38
- package/src/services/pdf/template-preview.ts +1 -1
- package/src/services/public-http.ts +28 -40
- package/src/services/settings/app.ts +4 -1
- package/src/services/settings/core-settings.ts +88 -0
- package/src/services/settings/defaults.ts +8 -2
- package/src/services/settings/errors.ts +11 -0
- package/src/services/settings/geo-budget.ts +40 -0
- package/src/services/settings/index.ts +4 -3
- package/src/services/settings/store.ts +25 -17
- package/src/services/weather/geo.ts +29 -111
- package/src/services/weather/index.ts +4 -3
- package/src/services/weather/locations.ts +8 -5
- package/src/services/weather/migrate.ts +1 -0
- package/src/services/weather/settings.ts +0 -16
- package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
- package/src/shared/avatar-art.ts +400 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/network-address.ts +22 -3
- package/src/shared/template-rendering.ts +36 -4
- package/src/shared-text/documents.ts +1 -1
- package/src/shared-text/index.ts +2 -0
- package/src/shared-text/log.ts +51 -3
- package/src/shared-text/snapshots.ts +340 -0
- package/src/ssr/AdminLayout.tsx +4 -2
- package/src/ssr/CloudShell.island.tsx +6 -0
- package/src/ssr/Layout.tsx +65 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
- package/src/ssr/LayoutHelp.tsx +3 -2
- package/src/ssr/PanelRuntime.island.tsx +11 -7
- package/src/ssr/PwaLayout.tsx +2 -2
- package/src/ssr/RailApps.tsx +6 -0
- package/src/ssr/ShellRuntime.island.tsx +23 -7
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +6 -6
- package/src/ssr/admin-navigation.ts +1 -0
- package/src/ssr/app-badges.ts +6 -1
- package/src/ssr/compact-shell.browser-harness.ts +44 -5
- package/src/ssr/help.ts +3 -2
- package/src/ssr/index.ts +1 -1
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/mobile-menu-history.ts +4 -0
- package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
- package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
- package/src/ssr/page-swap.browser-harness.tsx +177 -0
- package/src/ssr/page-swap.ts +28 -0
- package/src/ssr/page-trail-runtime.ts +32 -13
- package/src/ssr/panel-consent.ts +2 -2
- package/src/ssr/panel-slot.browser-harness.tsx +3 -0
- package/src/ssr/platform-messages.ts +8 -0
- package/src/ssr/runtime.ts +17 -0
- package/src/ssr/shell-data.tsx +10 -2
- package/src/styles/global.css +70 -6
- package/src/server/services/geo.ts +0 -215
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { type SQL, sql } from "bun";
|
|
2
|
+
import type { Context } from "hono";
|
|
3
|
+
import type { AppMeta } from "../contracts/app";
|
|
4
|
+
import { AppAccessRuleSchema, type FixedAppAccessRule } from "../contracts/app-access";
|
|
5
|
+
import { userFromActor } from "../server/actor";
|
|
6
|
+
import type { AuthContext, RequestAuthority } from "../server/middleware/auth";
|
|
7
|
+
import { buildAccessPrincipalCondition } from "../server/services/access";
|
|
8
|
+
import { logger } from "./logging";
|
|
9
|
+
import { toPgTextArray } from "./postgres";
|
|
10
|
+
|
|
11
|
+
type AccessApp = Pick<AppMeta, "id" | "access">;
|
|
12
|
+
type GrantState = { app_id: string; configured: boolean; permission: "read" | "admin" | null };
|
|
13
|
+
|
|
14
|
+
/** Read the already resolved identity; never reload unrestricted roles from its subject ID. */
|
|
15
|
+
export const appAccessAuthority = <E extends AuthContext>(c: Context<E>): RequestAuthority | null => {
|
|
16
|
+
const actor = c.get("actor");
|
|
17
|
+
const accessSubject = c.get("accessSubject");
|
|
18
|
+
const credentialKind = c.get("credentialKind");
|
|
19
|
+
if (!actor || !accessSubject || !credentialKind) return null;
|
|
20
|
+
return { actor, accessSubject, credentialKind, scopes: c.get("credentialScopes") ?? [] };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const credentialAllows = (authority: RequestAuthority, appId: string): boolean => {
|
|
24
|
+
const actor = authority.actor;
|
|
25
|
+
if (actor.kind === "service_account") {
|
|
26
|
+
if (actor.serviceAccount.status !== "active") return false;
|
|
27
|
+
if (actor.serviceAccount.kind === "resource_bound" && actor.serviceAccount.appId !== appId) return false;
|
|
28
|
+
if (actor.credentialExpiresAt && !(new Date(actor.credentialExpiresAt).getTime() > Date.now())) return false;
|
|
29
|
+
}
|
|
30
|
+
const credentialKind = authority.credentialKind === "invocation" ? actor.delegation?.credentialKind : authority.credentialKind;
|
|
31
|
+
if (authority.scopes.includes("ai:inference")) return false;
|
|
32
|
+
// Full personal API keys intentionally carry no scopes. Resource/standalone
|
|
33
|
+
// keys retain their caps; app-prefixed scopes are already used by applications.
|
|
34
|
+
if (credentialKind === "session" || credentialKind === "mandate") return true;
|
|
35
|
+
if (
|
|
36
|
+
credentialKind === "api_key" &&
|
|
37
|
+
actor.kind === "service_account" &&
|
|
38
|
+
actor.serviceAccount.kind === "user_delegated" &&
|
|
39
|
+
authority.scopes.length === 0
|
|
40
|
+
)
|
|
41
|
+
return true;
|
|
42
|
+
return authority.scopes.some((scope) =>
|
|
43
|
+
["read", "write", "admin", `${appId}:read`, `${appId}:write`, `${appId}:admin`, `${appId}:*`].includes(scope),
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const fixedAllows = (authority: RequestAuthority, rule: FixedAppAccessRule): boolean => {
|
|
48
|
+
const user = userFromActor(authority.actor);
|
|
49
|
+
switch (rule.kind) {
|
|
50
|
+
case "signedIn":
|
|
51
|
+
return true;
|
|
52
|
+
case "noGuests":
|
|
53
|
+
return user?.profile !== "guest";
|
|
54
|
+
case "roles":
|
|
55
|
+
return !!user && rule.roles.some((role) => (role === "guest" ? user.profile === "guest" : user.roles.includes(role)));
|
|
56
|
+
default:
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Pure decision shared by single-app and batched reads. Missing grant state means unconfigured, not an implicit grant. */
|
|
62
|
+
export const evaluateAppAccess = (authority: RequestAuthority | null, app: AccessApp, state?: GrantState): boolean => {
|
|
63
|
+
if (app.access === undefined) return true; // Step 1 compatibility; no inferred entry rule.
|
|
64
|
+
if (!authority || !credentialAllows(authority, app.id)) return false;
|
|
65
|
+
const parsed = AppAccessRuleSchema.safeParse(app.access);
|
|
66
|
+
if (!parsed.success) return false;
|
|
67
|
+
const rule = parsed.data;
|
|
68
|
+
if (rule.kind !== "grants") return fixedAllows(authority, rule);
|
|
69
|
+
if (!state?.configured) return !!rule.until && fixedAllows(authority, rule.until);
|
|
70
|
+
return state.permission === "read" || state.permission === "admin";
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
let lastFailureLog = -Infinity;
|
|
74
|
+
const reportFailure = () => {
|
|
75
|
+
if (Date.now() - lastFailureLog < 60_000) return;
|
|
76
|
+
lastFailureLog = Date.now();
|
|
77
|
+
logger("app-access").error("Application access could not be resolved; configurable applications are unavailable");
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One authoritative SQL read for all configurable apps. No grant cache: revocation applies on the next request.
|
|
82
|
+
* Pass the reserved transaction as `db` when checking access inside a guarded operation.
|
|
83
|
+
*/
|
|
84
|
+
export const usableAppIds = async (
|
|
85
|
+
authority: RequestAuthority | null,
|
|
86
|
+
apps: readonly AccessApp[],
|
|
87
|
+
options: { db?: SQL } = {},
|
|
88
|
+
): Promise<Set<string>> => {
|
|
89
|
+
const db = options.db ?? sql;
|
|
90
|
+
const allowed = new Set(apps.filter((app) => app.access?.kind !== "grants" && evaluateAppAccess(authority, app)).map((app) => app.id));
|
|
91
|
+
if (!authority) return allowed;
|
|
92
|
+
const configurable = apps.filter((app) => app.access?.kind === "grants" && credentialAllows(authority, app.id));
|
|
93
|
+
if (configurable.length === 0) return allowed;
|
|
94
|
+
try {
|
|
95
|
+
const principal = buildAccessPrincipalCondition({
|
|
96
|
+
subject: authority.accessSubject,
|
|
97
|
+
columns: {
|
|
98
|
+
userId: db`a.user_id`,
|
|
99
|
+
groupId: db`a.group_id`,
|
|
100
|
+
serviceAccountId: db`a.service_account_id`,
|
|
101
|
+
authenticatedOnly: db`a.authenticated_only`,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
const states = await db<GrantState[]>`
|
|
105
|
+
SELECT requested.app_id, s.configured_at IS NOT NULL AS configured,
|
|
106
|
+
(SELECT a.permission FROM auth.app_access j JOIN auth.access a ON a.id = j.access_id
|
|
107
|
+
WHERE j.app_id = requested.app_id AND a.permission IN ('read', 'admin')
|
|
108
|
+
AND (a.user_id IS NOT NULL OR a.group_id IS NOT NULL OR a.service_account_id IS NOT NULL OR a.authenticated_only)
|
|
109
|
+
AND ${principal}
|
|
110
|
+
ORDER BY CASE a.permission WHEN 'admin' THEN 2 ELSE 1 END DESC LIMIT 1) AS permission
|
|
111
|
+
FROM unnest(${toPgTextArray(configurable.map((app) => app.id))}::text[]) AS requested(app_id)
|
|
112
|
+
LEFT JOIN auth.app_access_state s ON s.app_id = requested.app_id
|
|
113
|
+
`;
|
|
114
|
+
const byApp = new Map(states.map((state) => [state.app_id, state]));
|
|
115
|
+
for (const app of configurable) if (evaluateAppAccess(authority, app, byApp.get(app.id))) allowed.add(app.id);
|
|
116
|
+
} catch {
|
|
117
|
+
reportFailure();
|
|
118
|
+
}
|
|
119
|
+
return allowed;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const canUseApp = async (authority: RequestAuthority | null, app: AccessApp, options: { db?: SQL } = {}): Promise<boolean> =>
|
|
123
|
+
(await usableAppIds(authority, [app], options)).has(app.id);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { listApps } from "../_internal/registry";
|
|
2
|
+
import type { AppMeta } from "../contracts/app";
|
|
3
|
+
import type { AppRegistryEntry } from "../contracts/registry";
|
|
4
|
+
import type { RequestAuthority } from "../server/middleware/auth";
|
|
5
|
+
import { usableAppIds } from "./app-access";
|
|
6
|
+
|
|
7
|
+
/** Filter before projecting any app-owned names, schemas, or content. */
|
|
8
|
+
export const discoverableApps = async <T extends Pick<AppMeta, "id" | "access">>(
|
|
9
|
+
authority: RequestAuthority | null,
|
|
10
|
+
apps: readonly T[],
|
|
11
|
+
): Promise<T[]> => {
|
|
12
|
+
const usable = await usableAppIds(authority, apps);
|
|
13
|
+
return apps.filter((app) => usable.has(app.id));
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Capability and widget records carry an app ID; the live registry owns their access declaration. */
|
|
17
|
+
export const discoverableAppEntries = async <T extends { appId: string }>(
|
|
18
|
+
authority: RequestAuthority | null,
|
|
19
|
+
entries: readonly T[],
|
|
20
|
+
registry: () => Promise<AppRegistryEntry[]> = listApps,
|
|
21
|
+
): Promise<T[]> => {
|
|
22
|
+
if (!entries.length) return [];
|
|
23
|
+
const ids = new Set(entries.map((entry) => entry.appId));
|
|
24
|
+
const apps = (await registry()).filter((app) => ids.has(app.id));
|
|
25
|
+
const usable = await usableAppIds(authority, apps);
|
|
26
|
+
return entries.filter((entry) => usable.has(entry.appId));
|
|
27
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { parseStoredReviewContent } from "../../capabilities/review-storage";
|
|
3
|
+
import { CAPABILITY_MAX_REVIEW_CONTENT_BYTES } from "../../contracts/capabilities";
|
|
4
|
+
|
|
5
|
+
// Start with this person's indexed pending rows; a Studio page alone cannot continue a chat.
|
|
6
|
+
export const openRequests = (approverUserId: string, id?: string) => sql`SELECT r.* FROM approvals.requests r
|
|
7
|
+
WHERE r.approver_user_id=${approverUserId}::uuid AND r.status='pending' AND r.expires_at > now()
|
|
8
|
+
AND r.approver_auth_epoch=(SELECT auth_epoch FROM auth.users WHERE id=${approverUserId}::uuid)
|
|
9
|
+
AND (${id ?? null}::uuid IS NULL OR r.id=${id ?? null}::uuid)
|
|
10
|
+
AND EXISTS (SELECT 1 FROM ai.pending_actions p WHERE p.approval_id=r.id AND p.status='pending')`;
|
|
11
|
+
|
|
12
|
+
/** Keep the original approval's digest and reader even when the turn has moved to a newer question. */
|
|
13
|
+
export const compactApprovalDetailsSql = (row = "r") => {
|
|
14
|
+
const details = sql`${sql(`${row}.details`)}`;
|
|
15
|
+
return sql`CASE WHEN jsonb_typeof(${details}) = 'object' THEN (${details} - 'content') || CASE WHEN ${details} ? 'content' THEN jsonb_build_object(
|
|
16
|
+
'contentRef', jsonb_build_object('href', '/api/approvals/' || ${sql(`${row}.id`)}::text || '/content', 'digest', ${sql(`${row}.review_digest`)})
|
|
17
|
+
) ELSE '{}'::jsonb END ELSE ${details} END`;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Open requests retain the existing approver, pending-action, expiry and sign-out-epoch requirements. */
|
|
21
|
+
export const readApprovalContent = async (input: { id: string; approverUserId: string; digest: string; signal?: AbortSignal }) => {
|
|
22
|
+
input.signal?.throwIfAborted();
|
|
23
|
+
const query = sql<{ content: unknown }[]>`
|
|
24
|
+
SELECT r.details->'content' AS content FROM (${openRequests(input.approverUserId, input.id)}) r
|
|
25
|
+
WHERE r.review_digest=${input.digest}
|
|
26
|
+
AND octet_length((r.details->'content')::text) <= ${2 * CAPABILITY_MAX_REVIEW_CONTENT_BYTES + 2} LIMIT 1`;
|
|
27
|
+
const abort = () => query.cancel();
|
|
28
|
+
input.signal?.addEventListener("abort", abort, { once: true });
|
|
29
|
+
if (input.signal?.aborted) abort();
|
|
30
|
+
let rows: { content: unknown }[];
|
|
31
|
+
try {
|
|
32
|
+
rows = await query;
|
|
33
|
+
} finally {
|
|
34
|
+
input.signal?.removeEventListener("abort", abort);
|
|
35
|
+
}
|
|
36
|
+
input.signal?.throwIfAborted();
|
|
37
|
+
const row = rows[0];
|
|
38
|
+
const parsed = parseStoredReviewContent(row?.content);
|
|
39
|
+
return parsed.success ? parsed.data : null;
|
|
40
|
+
};
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { type SQL, sql } from "bun";
|
|
2
2
|
import { capabilityRequestHash } from "../../capabilities/claims";
|
|
3
|
+
import { restoreReviewSnapshot, serializeReviewSnapshot } from "../../capabilities/review-storage";
|
|
3
4
|
import { APPROVAL_ACP_CLIENT } from "../../contracts/approvals";
|
|
4
5
|
import type { CapabilityActionReviewData } from "../../contracts/capabilities";
|
|
6
|
+
import { CapabilityActionReviewPresentationSchema } from "../../contracts/capabilities";
|
|
5
7
|
import { isAppSessionActor } from "../../server/actor";
|
|
6
8
|
import type { RequestActor, RequestCredentialKind } from "../../server/middleware/auth";
|
|
7
9
|
import { audit } from "../audit";
|
|
8
10
|
import { toPgTextArray } from "../postgres";
|
|
11
|
+
import { compactApprovalDetailsSql } from "./content";
|
|
9
12
|
|
|
10
13
|
export { countPending, listPending, presentPendingApproval } from "./list";
|
|
11
14
|
export { migrateApprovals } from "./migrate";
|
|
@@ -83,10 +86,10 @@ export type ApprovalRequest = {
|
|
|
83
86
|
};
|
|
84
87
|
// SQL aliases keep the public row shape identical for reads and locked transaction reads. A function, because a
|
|
85
88
|
// fragment built at import time would bind the default sql handle before a test can choose its database.
|
|
86
|
-
const columns = () => sql`id, app_id AS "appId", action, approval_scope AS "approvalScope",
|
|
89
|
+
const columns = (compact = false) => sql`id, app_id AS "appId", action, approval_scope AS "approvalScope",
|
|
87
90
|
requester_kind AS "requesterKind", requester_ref AS "requesterRef", requester_app_id AS "requesterAppId",
|
|
88
91
|
requester_title AS "requesterTitle", subject_id AS "subjectId", approver_user_id AS "approverUserId",
|
|
89
|
-
assurance, sentence, details, args_digest AS "argsDigest", review_digest AS "reviewDigest", status,
|
|
92
|
+
assurance, sentence, ${compact ? compactApprovalDetailsSql("approvals.requests") : sql`details`} AS details, args_digest AS "argsDigest", review_digest AS "reviewDigest", status,
|
|
90
93
|
decided_device_id AS "decidedDeviceId", approver_auth_epoch::text AS "approverAuthEpoch",
|
|
91
94
|
decided_via AS "decidedVia", decided_at AS "decidedAt", consumed_at AS "consumedAt",
|
|
92
95
|
created_at AS "createdAt", expires_at AS "expiresAt"`;
|
|
@@ -122,7 +125,7 @@ export const request = async (input: ApprovalRequestInput, db?: SQL): Promise<Ap
|
|
|
122
125
|
requester_title, subject_id, approver_user_id, approver_auth_epoch, assurance, sentence, details, args_digest, review_digest, expires_at)
|
|
123
126
|
VALUES (${input.appId}, ${input.action}, ${input.approvalScope ?? null}, ${input.requester.kind}, ${input.requester.ref},
|
|
124
127
|
${input.requester.appId}, ${input.requester.title ?? null}, ${input.subjectId}::uuid, ${input.approverUserId}::uuid,
|
|
125
|
-
(SELECT auth_epoch FROM auth.users WHERE id=${input.approverUserId}::uuid), ${input.assurance}, ${input.sentence}, ${
|
|
128
|
+
(SELECT auth_epoch FROM auth.users WHERE id=${input.approverUserId}::uuid), ${input.assurance}, ${input.sentence}, ${serializeReviewSnapshot(input.review ?? null)}::text::jsonb,
|
|
126
129
|
${approvalArgsDigest(input.input)}, ${approvalReviewDigest(input.review)}, now() + (${expiresInMs} * interval '1 millisecond'))
|
|
127
130
|
ON CONFLICT (requester_kind, requester_ref) DO NOTHING
|
|
128
131
|
RETURNING id
|
|
@@ -137,10 +140,15 @@ export const request = async (input: ApprovalRequestInput, db?: SQL): Promise<Ap
|
|
|
137
140
|
});
|
|
138
141
|
};
|
|
139
142
|
|
|
140
|
-
export const get = async (id: string, db?: SQL): Promise<ApprovalRequest | null> =>
|
|
143
|
+
export const get = async (id: string, db?: SQL, compactReview = false): Promise<ApprovalRequest | null> =>
|
|
141
144
|
transaction(db, async (tx) => {
|
|
142
|
-
const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests WHERE id=${id}::uuid`;
|
|
143
|
-
return row
|
|
145
|
+
const [row] = await tx<ApprovalRequest[]>`SELECT ${columns(compactReview)} FROM approvals.requests WHERE id=${id}::uuid`;
|
|
146
|
+
return row
|
|
147
|
+
? {
|
|
148
|
+
...row,
|
|
149
|
+
details: row.details === null ? null : CapabilityActionReviewPresentationSchema.parse(restoreReviewSnapshot(row.details)),
|
|
150
|
+
}
|
|
151
|
+
: null;
|
|
144
152
|
});
|
|
145
153
|
|
|
146
154
|
export type ApprovalDecisionResult =
|
|
@@ -4,8 +4,9 @@ import { AI_WEBSITE_APPROVAL_TOOL, aiTurnAllowsRememberedApprovals } from "../..
|
|
|
4
4
|
import type { AiTurnRunConfig } from "../../ai/types";
|
|
5
5
|
import type { RuntimeAppMeta } from "../../contracts/app";
|
|
6
6
|
import { APPROVALS_PAGE_SIZE, type ApprovalCounts, type PendingApproval } from "../../contracts/approvals";
|
|
7
|
-
import { type CapabilityActionPolicy,
|
|
7
|
+
import { type CapabilityActionPolicy, CapabilityActionReviewPresentationSchema } from "../../contracts/capabilities";
|
|
8
8
|
import type { ApprovalAssurance } from ".";
|
|
9
|
+
import { compactApprovalDetailsSql, openRequests } from "./content";
|
|
9
10
|
import { decodeApprovalCursor, encodeApprovalCursor } from "./cursor";
|
|
10
11
|
|
|
11
12
|
export type PendingApprovalRow = {
|
|
@@ -32,12 +33,6 @@ export type PendingApprovalRow = {
|
|
|
32
33
|
runConfig: AiTurnRunConfig | null;
|
|
33
34
|
};
|
|
34
35
|
|
|
35
|
-
// Start with this person's indexed pending rows; a Studio page alone cannot continue a chat.
|
|
36
|
-
export const openRequests = (approverUserId: string, id?: string) => sql`SELECT r.* FROM approvals.requests r
|
|
37
|
-
WHERE r.approver_user_id=${approverUserId}::uuid AND r.status='pending' AND r.expires_at > now()
|
|
38
|
-
AND r.approver_auth_epoch=(SELECT auth_epoch FROM auth.users WHERE id=${approverUserId}::uuid)
|
|
39
|
-
AND (${id ?? null}::uuid IS NULL OR r.id=${id ?? null}::uuid)
|
|
40
|
-
AND EXISTS (SELECT 1 FROM ai.pending_actions p WHERE p.approval_id=r.id AND p.status='pending')`;
|
|
41
36
|
const matches = (appId?: string, link?: string) => sql`
|
|
42
37
|
(${appId ?? null}::text IS NULL OR r.app_id=${appId ?? null} OR r.requester_app_id=${appId ?? null})
|
|
43
38
|
AND (${link ?? null}::text IS NULL OR COALESCE(r.details->'links' @> jsonb_build_array(jsonb_build_object('href', ${link ?? null}::text)),false))`;
|
|
@@ -58,7 +53,7 @@ export const listPending = async (input: {
|
|
|
58
53
|
SELECT count(*) FILTER (WHERE ${matches(input.appId, input.link)})::int AS count,
|
|
59
54
|
count(*) FILTER (WHERE NOT (${matches(input.appId, input.link)}))::int AS elsewhere FROM open r`;
|
|
60
55
|
const rows = await sql<PendingApprovalRow[]>`WITH open AS (${openRequests(input.approverUserId, input.id)})
|
|
61
|
-
SELECT r.id, r.app_id AS "appId", r.action, r.requester_app_id AS "requesterAppId", r.requester_title AS "requesterTitle", r.assurance, r.sentence,
|
|
56
|
+
SELECT r.id, r.app_id AS "appId", r.action, r.requester_app_id AS "requesterAppId", r.requester_title AS "requesterTitle", r.assurance, r.sentence, ${compactApprovalDetailsSql()} AS details, r.args_digest AS "argsDigest",
|
|
62
57
|
r.created_at AS "createdAt", r.expires_at AS "expiresAt",
|
|
63
58
|
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS "cursorCreatedAt",
|
|
64
59
|
question.short_id AS "conversationShortId", question.title AS "conversationTitle",
|
|
@@ -67,7 +62,7 @@ export const listPending = async (input: {
|
|
|
67
62
|
question.stored_review AS "storedReview"
|
|
68
63
|
FROM open r JOIN LATERAL (
|
|
69
64
|
SELECT c.short_id, c.title, p.allow_chat, p.allow_always, p.remember_tool_name, p.tool_name, t.run_config,
|
|
70
|
-
(SELECT m.meta->'toolReviews'->review.call_id FROM (
|
|
65
|
+
(SELECT (m.meta->'toolReviews'->review.call_id) - 'content' - 'contentRef' FROM (
|
|
71
66
|
SELECT meta, conversation_id, loop_id, role, seq FROM ai.messages
|
|
72
67
|
UNION ALL
|
|
73
68
|
SELECT meta, conversation_id, loop_id, role, seq FROM ai.task_messages
|
|
@@ -109,12 +104,12 @@ export const presentPendingApproval = (
|
|
|
109
104
|
policy?: CapabilityActionPolicy,
|
|
110
105
|
): PendingApproval => {
|
|
111
106
|
const app = apps.find((entry) => entry.id === row.appId);
|
|
112
|
-
const review =
|
|
107
|
+
const review = CapabilityActionReviewPresentationSchema.nullable().safeParse(row.details);
|
|
113
108
|
const originalReview = review.success ? review.data : null;
|
|
114
109
|
const displayReview =
|
|
115
110
|
originalReview && "sentence" in originalReview
|
|
116
111
|
? row.storedReview && "sentence" in row.storedReview
|
|
117
|
-
? row.storedReview
|
|
112
|
+
? { ...originalReview, ask: originalReview.ask ?? row.storedReview.ask }
|
|
118
113
|
: storedActionReview(originalReview, policy)
|
|
119
114
|
: originalReview;
|
|
120
115
|
const remember = row.assurance === "standard" && aiTurnAllowsRememberedApprovals(row.runConfig);
|
|
@@ -3,9 +3,8 @@ import { type AiStoredActionReview, storedActionReview } from "../../ai/action-r
|
|
|
3
3
|
import type { ActionCard } from "../../ai/chat/action-card";
|
|
4
4
|
import { approvalCard } from "../../approvals/approval-card";
|
|
5
5
|
import {
|
|
6
|
-
CapabilityActionCallReviewSchema,
|
|
7
6
|
type CapabilityActionPolicy,
|
|
8
|
-
|
|
7
|
+
CapabilityActionReviewPresentationSchema,
|
|
9
8
|
capabilityActionPolicy,
|
|
10
9
|
} from "../../contracts/capabilities";
|
|
11
10
|
import { DEFAULT_LOCALE } from "../../server/locale";
|
|
@@ -23,8 +22,8 @@ import type { PendingApprovalRow } from "./list";
|
|
|
23
22
|
export type ApprovalActionPolicy = CapabilityActionPolicy | null | undefined;
|
|
24
23
|
const needsPolicy = (row: PendingApprovalRow): boolean => {
|
|
25
24
|
if (row.storedReview && "sentence" in row.storedReview) return true;
|
|
26
|
-
const review =
|
|
27
|
-
return review.success;
|
|
25
|
+
const review = CapabilityActionReviewPresentationSchema.safeParse(row.details);
|
|
26
|
+
return review.success && "sentence" in review.data;
|
|
28
27
|
};
|
|
29
28
|
/** Reads the registry once for the rows that need their Action's declared policy, as the approvals page does. */
|
|
30
29
|
export const loadApprovalActionPolicies = async (
|
|
@@ -41,13 +40,18 @@ export const loadApprovalActionPolicies = async (
|
|
|
41
40
|
};
|
|
42
41
|
};
|
|
43
42
|
|
|
44
|
-
/**
|
|
43
|
+
/** The open request binds the content; the turn supplies the declared reason for older snapshots. */
|
|
45
44
|
export const pendingApprovalReview = (
|
|
46
45
|
row: Pick<PendingApprovalRow, "storedReview" | "details">,
|
|
47
46
|
policy?: ApprovalActionPolicy,
|
|
48
47
|
): AiStoredActionReview | null => {
|
|
49
|
-
const parsed =
|
|
50
|
-
|
|
48
|
+
const parsed = CapabilityActionReviewPresentationSchema.safeParse(row.details);
|
|
49
|
+
if (parsed.success && "sentence" in parsed.data)
|
|
50
|
+
return storedActionReview(
|
|
51
|
+
{ ...parsed.data, ask: parsed.data.ask ?? (row.storedReview && "sentence" in row.storedReview ? row.storedReview.ask : undefined) },
|
|
52
|
+
policy,
|
|
53
|
+
);
|
|
54
|
+
return row.storedReview ?? (parsed.success ? storedActionReview(parsed.data, policy) : null);
|
|
51
55
|
};
|
|
52
56
|
|
|
53
57
|
/** Resolve the recorded question in the language and time zone of its originating turn. */
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { env } from "../config/env";
|
|
2
|
+
import { logger } from "./logging";
|
|
3
|
+
import { requestCacheRedis } from "./request-cache-redis";
|
|
4
|
+
|
|
5
|
+
// Preserve the existing single-process PDF and immediate SMTP limits as the
|
|
6
|
+
// installation's baseline. Local counters still protect each process's memory.
|
|
7
|
+
const CAPACITY = { gotenberg: 2, smtp: 8 } as const;
|
|
8
|
+
type Downstream = keyof typeof CAPACITY;
|
|
9
|
+
type Client = { send(command: string, args: string[]): Promise<unknown> };
|
|
10
|
+
|
|
11
|
+
const ACQUIRE = `
|
|
12
|
+
local clock = redis.call('TIME')
|
|
13
|
+
local now = tonumber(clock[1]) * 1000 + math.floor(tonumber(clock[2]) / 1000)
|
|
14
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)
|
|
15
|
+
if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[1]) then return 0 end
|
|
16
|
+
redis.call('ZADD', KEYS[1], now + tonumber(ARGV[2]), ARGV[3])
|
|
17
|
+
local last = redis.call('ZRANGE', KEYS[1], -1, -1, 'WITHSCORES')
|
|
18
|
+
redis.call('PEXPIRE', KEYS[1], math.ceil(tonumber(last[2]) - now))
|
|
19
|
+
return 1
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
export class DownstreamBusyError extends Error {
|
|
23
|
+
constructor(readonly downstream: Downstream) {
|
|
24
|
+
super(`${downstream} is busy; retry later.`);
|
|
25
|
+
this.name = "DownstreamBusyError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** No admission queue: one atomic attempt, bounded by the caller's operation deadline. */
|
|
30
|
+
export const createDownstreamBudget = (options: { client?: () => Promise<Client>; namespace?: () => string } = {}) => ({
|
|
31
|
+
async acquire(input: { downstream: Downstream; timeoutMs: number; signal: AbortSignal }): Promise<() => void> {
|
|
32
|
+
input.signal.throwIfAborted();
|
|
33
|
+
if (!Number.isFinite(input.timeoutMs) || input.timeoutMs <= 0) throw new RangeError("Downstream timeout must be positive and finite");
|
|
34
|
+
const namespace = options.namespace?.() ?? env.SYNC_NAMESPACE;
|
|
35
|
+
if (!namespace.trim()) throw new Error("SYNC_NAMESPACE is required for shared downstream budgets");
|
|
36
|
+
const key = `cloud:downstream:{${namespace}}:${input.downstream}`;
|
|
37
|
+
const token = crypto.randomUUID();
|
|
38
|
+
let client: Client | undefined;
|
|
39
|
+
const release = () => {
|
|
40
|
+
// Release does not delay a response or shutdown during a Valkey outage.
|
|
41
|
+
// Tokens also expire after twice the operation deadline, allowing time
|
|
42
|
+
// for aborted HTTP/SMTP sockets to close before a crashed slot is reused.
|
|
43
|
+
try {
|
|
44
|
+
void client?.send("ZREM", [key, token]).catch(() => {});
|
|
45
|
+
} catch {
|
|
46
|
+
// A synchronous client failure must not turn SMTP success into a retry.
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
let cancelled = false;
|
|
50
|
+
let onAbort: () => void = () => {};
|
|
51
|
+
const admission = (async () => {
|
|
52
|
+
client = await (options.client ?? requestCacheRedis)();
|
|
53
|
+
if (cancelled) return false;
|
|
54
|
+
const admitted =
|
|
55
|
+
Number(await client.send("EVAL", [ACQUIRE, "1", key, String(CAPACITY[input.downstream]), String(input.timeoutMs * 2), token])) ===
|
|
56
|
+
1;
|
|
57
|
+
if (cancelled && admitted) release();
|
|
58
|
+
return admitted;
|
|
59
|
+
})();
|
|
60
|
+
try {
|
|
61
|
+
const admitted = await Promise.race([
|
|
62
|
+
admission,
|
|
63
|
+
new Promise<never>((_, reject) => {
|
|
64
|
+
onAbort = () => {
|
|
65
|
+
cancelled = true;
|
|
66
|
+
reject(input.signal.reason);
|
|
67
|
+
};
|
|
68
|
+
input.signal.addEventListener("abort", onAbort, { once: true });
|
|
69
|
+
if (input.signal.aborted) onAbort();
|
|
70
|
+
}),
|
|
71
|
+
]);
|
|
72
|
+
if (!admitted) throw new DownstreamBusyError(input.downstream);
|
|
73
|
+
return release;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (input.signal.aborted) throw input.signal.reason;
|
|
76
|
+
if (!(error instanceof DownstreamBusyError)) {
|
|
77
|
+
// Do not expose connection details, which can include credentials.
|
|
78
|
+
logger("downstream-budget").warn("Downstream admission unavailable", { downstream: input.downstream });
|
|
79
|
+
}
|
|
80
|
+
throw new DownstreamBusyError(input.downstream);
|
|
81
|
+
} finally {
|
|
82
|
+
input.signal.removeEventListener("abort", onAbort);
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export const downstreamBudget = createDownstreamBudget();
|