@k2b/cloud 0.40.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 +9 -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-sentences.ts +2 -1
- package/src/_internal/capability-timeout.ts +71 -0
- package/src/_internal/define-app.ts +158 -92
- package/src/_internal/heartbeat.ts +203 -14
- package/src/_internal/http-drain.ts +56 -0
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/registry.ts +28 -1
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/setup-lock.ts +103 -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 +76 -4
- 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 +214 -23
- package/src/ai/chat/action-text.ts +288 -0
- package/src/ai/chat/tool-action-words.ts +31 -0
- package/src/ai/chat/tool-action.tsx +1 -26
- 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 +64 -21
- package/src/ai/fetch-file-tool.ts +67 -76
- package/src/ai/firecrawl-tools.ts +15 -2
- package/src/ai/http.ts +1 -0
- package/src/ai/index.ts +1 -0
- package/src/ai/migrate.ts +28 -0
- package/src/ai/open-questions.ts +168 -0
- package/src/ai/projects.ts +1 -2
- package/src/ai/protocol.ts +2 -2
- package/src/ai/question-text.ts +8 -0
- package/src/ai/recent-resources.ts +12 -5
- package/src/ai/review-content.ts +77 -0
- package/src/ai/routes.ts +119 -35
- 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 +184 -31
- package/src/ai/testing.ts +36 -0
- package/src/ai/tool-sentences.ts +152 -34
- package/src/ai/tool-wording.ts +12 -6
- package/src/ai/tools.ts +8 -3
- package/src/ai/types.ts +25 -4
- 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/app-approval-actions.ts +31 -7
- 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/auth/schemas.ts +3 -0
- package/src/api/auth.ts +54 -18
- 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 +73 -41
- package/src/api/me-panel-consent.ts +8 -5
- package/src/api/me.ts +79 -3
- 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 +30 -66
- package/src/approvals/approval-card.ts +68 -0
- 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-approval.ts +3 -3
- 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 +149 -55
- 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/contracts/shared.ts +3 -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 +5 -3
- 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/app.ts +23 -3
- package/src/services/accounts/posix.ts +21 -7
- package/src/services/accounts/users.ts +12 -1
- 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/device.ts +49 -33
- package/src/services/approvals/index.ts +15 -6
- package/src/services/approvals/list.ts +6 -11
- package/src/services/approvals/question.ts +72 -0
- package/src/services/audit/index.ts +1 -0
- package/src/services/auth-flows/ipa.ts +39 -2
- package/src/services/auth-flows/password-reset.ts +32 -6
- 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 +11 -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/markdown.ts +160 -2
- package/src/services/pdf/template-preview.ts +1 -1
- package/src/services/public-http.ts +28 -40
- package/src/services/session/index.ts +203 -29
- 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/HelpPanelArticle.island.tsx +308 -0
- package/src/ssr/HelpPanelBack.island.tsx +32 -0
- package/src/ssr/HelpPanelMenu.island.tsx +133 -0
- package/src/ssr/HelpPanelSearch.island.tsx +123 -0
- package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
- package/src/ssr/HelpPanelView.island.tsx +27 -0
- package/src/ssr/Layout.tsx +65 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
- package/src/ssr/LayoutHelp.tsx +185 -542
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
- package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
- package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
- 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 +29 -0
- 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 +7 -5
- package/src/ssr/index.ts +2 -1
- package/src/ssr/islands/index.ts +3 -4
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-panel-controls.tsx +43 -0
- package/src/ssr/layout-help-panel-document.ts +67 -0
- package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
- package/src/ssr/layout-help-panel-menu.tsx +113 -0
- package/src/ssr/layout-help-panel-modules.ts +7 -0
- package/src/ssr/layout-help-panel-reader.tsx +159 -0
- package/src/ssr/layout-help-panel-search.tsx +69 -0
- package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
- package/src/ssr/layout-help-panel-state.ts +141 -0
- package/src/ssr/layout-help-panel-types.ts +19 -0
- package/src/ssr/layout-help-views.tsx +484 -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-idle.ts +9 -0
- 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/approvals.css +6 -0
- package/src/styles/effects.css +15 -0
- package/src/styles/global.css +70 -6
- package/src/server/services/geo.ts +0 -215
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { logger } from "./logging";
|
|
2
|
+
import { requestCacheRedis } from "./request-cache-redis";
|
|
3
|
+
|
|
4
|
+
const KEY = "cloud:app-access:denied";
|
|
5
|
+
let lastFailureLog = -Infinity;
|
|
6
|
+
|
|
7
|
+
/** Count denied entry/invocation attempts, never discovery filtering or audit rows. Call only for registered app IDs. */
|
|
8
|
+
export const recordAppAccessDenied = (appId: string): void => {
|
|
9
|
+
void requestCacheRedis()
|
|
10
|
+
.then((redis) => redis.send("HINCRBY", [KEY, appId, "1"]))
|
|
11
|
+
.catch(() => {
|
|
12
|
+
if (Date.now() - lastFailureLog < 60_000) return;
|
|
13
|
+
lastFailureLog = Date.now();
|
|
14
|
+
logger("app-access").warn("Application access denial metrics are unavailable");
|
|
15
|
+
});
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** The metric collector reads only the current registry's bounded set of app labels. */
|
|
19
|
+
export const readAppAccessDenials = async (appIds: readonly string[]): Promise<{ app: string; count: number }[]> => {
|
|
20
|
+
if (!appIds.length) return [];
|
|
21
|
+
const values: unknown = await (await requestCacheRedis()).send("HMGET", [KEY, ...appIds]);
|
|
22
|
+
if (!Array.isArray(values)) throw new Error("Invalid application access metrics response");
|
|
23
|
+
return appIds.map((app, index) => ({ app, count: Number(values[index] ?? 0) }));
|
|
24
|
+
};
|
|
@@ -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,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { listApps } from "../../_internal/registry";
|
|
2
|
+
import type { ActionCard } from "../../ai/chat/action-card";
|
|
3
|
+
import { type ActionQuestionText, actionCardText, actionNoteLabel } from "../../ai/chat/action-text";
|
|
2
4
|
import {
|
|
3
5
|
APP_APPROVAL_LIMITS,
|
|
4
6
|
type AppActionSummary,
|
|
@@ -6,9 +8,12 @@ import {
|
|
|
6
8
|
appActionDigest,
|
|
7
9
|
APP_ACTION_LIMITS as limits,
|
|
8
10
|
} from "../../contracts/app-approval";
|
|
9
|
-
import { CapabilityActionReviewDataSchema } from "../../contracts/capabilities";
|
|
10
11
|
import { DEFAULT_LOCALE } from "../../server/locale";
|
|
12
|
+
import { resolveAppIdentityPresentation } from "../../shared/app-presentation";
|
|
13
|
+
import { get } from ".";
|
|
11
14
|
import type { PendingApprovalRow } from "./list";
|
|
15
|
+
import { listPending } from "./list";
|
|
16
|
+
import { type ApprovalActionPolicy, loadApprovalActionPolicies, pendingApprovalCard } from "./question";
|
|
12
17
|
|
|
13
18
|
const plain = (value: string): string =>
|
|
14
19
|
value
|
|
@@ -22,12 +27,26 @@ const cut = (value: string, limit: number): string => {
|
|
|
22
27
|
return points.length > limit ? `${points.slice(0, limit - 1).join("")}…` : value;
|
|
23
28
|
};
|
|
24
29
|
|
|
30
|
+
export { pendingApprovalCard };
|
|
31
|
+
|
|
25
32
|
export const appApprovalText = (value: string, limit: number): string => cut(plain(value), limit);
|
|
26
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Incomplete sends must be decided with the complete review in the browser. Without the declared policy Cloud cannot
|
|
36
|
+
* rule out `sends`, so an incomplete question then stays in the browser too.
|
|
37
|
+
*/
|
|
38
|
+
export const appActionOffered = (view: AppActionView, question: ActionCard | ActionQuestionText, policy?: ApprovalActionPolicy): boolean =>
|
|
39
|
+
!(
|
|
40
|
+
view.truncated &&
|
|
41
|
+
(policy === null ||
|
|
42
|
+
("reasonCode" in question ? question.reasonCode : question.reason.code) === "sends" ||
|
|
43
|
+
(policy && "ask" in policy && policy.ask === "sends"))
|
|
44
|
+
);
|
|
45
|
+
|
|
27
46
|
/** A deterministic, plain-text snapshot; the digest binds the device's choice to everything it shows. */
|
|
28
47
|
export const appActionView = async (
|
|
29
48
|
row: PendingApprovalRow,
|
|
30
|
-
options: { issuer: string; appName: (appId: string, locale: string) => string },
|
|
49
|
+
options: { issuer: string; appName: (appId: string, locale: string) => string; policy?: ApprovalActionPolicy },
|
|
31
50
|
): Promise<AppActionView> => {
|
|
32
51
|
let truncated = false;
|
|
33
52
|
const text = (value: string, limit: number) => {
|
|
@@ -44,41 +63,20 @@ export const appActionView = async (
|
|
|
44
63
|
// The snapshot, so renaming the chat does not change what the device signs.
|
|
45
64
|
title: text(row.requesterTitle ?? "", limits.titleChars) || null,
|
|
46
65
|
};
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
const prefix = `${appName}: `;
|
|
50
|
-
const sentence = first.startsWith(prefix) && first.length > prefix.length ? first.slice(prefix.length) : first;
|
|
51
|
-
const review = CapabilityActionReviewDataSchema.safeParse(row.details);
|
|
52
|
-
let message: string | null;
|
|
66
|
+
const question = actionCardText(pendingApprovalCard(row, options.appName, options.policy));
|
|
67
|
+
truncated ||= question.incomplete;
|
|
53
68
|
const details: AppActionView["details"] = [];
|
|
54
|
-
|
|
69
|
+
for (const field of question.rows) {
|
|
55
70
|
if (details.length >= limits.details) {
|
|
56
71
|
truncated = true;
|
|
57
|
-
|
|
72
|
+
break;
|
|
58
73
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
details.push({ label
|
|
62
|
-
};
|
|
63
|
-
const messages: string[] = [];
|
|
64
|
-
const reviewLines = new Set<string>();
|
|
65
|
-
if (review.success) {
|
|
66
|
-
const view = capabilityReviewView(review.data, { timeZone: "UTC" });
|
|
67
|
-
const cleaned = plain(view.message);
|
|
68
|
-
if (cleaned !== sentence) messages.push(cleaned);
|
|
69
|
-
reviewLines.add(cleaned);
|
|
70
|
-
for (const line of view.message.split(/\r?\n/)) reviewLines.add(plain(line));
|
|
71
|
-
for (const field of view.details ?? [])
|
|
72
|
-
detail(field.label, field.value, field.format === "date" || field.format === "date-time" ? field.format : undefined);
|
|
73
|
-
}
|
|
74
|
-
for (const line of lines.slice(1)) {
|
|
75
|
-
if (reviewLines.has(line)) continue;
|
|
76
|
-
const match = /^([^:]{1,40}):\s+(.+)$/.exec(line);
|
|
77
|
-
if (match) detail(match[1]!, match[2]!);
|
|
78
|
-
else messages.push(line);
|
|
74
|
+
// The device protocol requires labels; a line an old review left unlabelled is shown as a note, not dropped.
|
|
75
|
+
const label = text(field.label, limits.labelChars) || text(actionNoteLabel(question.locale), limits.labelChars);
|
|
76
|
+
details.push({ label, value: text(field.value, limits.valueChars), ...(field.format ? { format: field.format } : {}) });
|
|
79
77
|
}
|
|
80
|
-
message = text(
|
|
81
|
-
const boundedSentence = text(
|
|
78
|
+
const message = text(question.reason, limits.messageChars) || null;
|
|
79
|
+
const boundedSentence = text(question.question, limits.sentenceChars);
|
|
82
80
|
const view: AppActionView = {
|
|
83
81
|
actionId: row.id,
|
|
84
82
|
app,
|
|
@@ -114,3 +112,21 @@ export const appActionSummary = (view: AppActionView): AppActionSummary => ({
|
|
|
114
112
|
createdAt: view.createdAt,
|
|
115
113
|
expiresAt: view.expiresAt,
|
|
116
114
|
});
|
|
115
|
+
|
|
116
|
+
/** Whether a pending approval can wake a device under the same mapping used by its inbox and detail view. */
|
|
117
|
+
export const deviceOffersApproval = async (approvalId: string): Promise<boolean> => {
|
|
118
|
+
const request = await get(approvalId);
|
|
119
|
+
if (!request) return false;
|
|
120
|
+
const { items } = await listPending({ approverUserId: request.approverUserId, id: approvalId, limit: 1 });
|
|
121
|
+
const row = items[0];
|
|
122
|
+
if (!row) return false;
|
|
123
|
+
const apps = await listApps();
|
|
124
|
+
const appName = (id: string, locale: string) => {
|
|
125
|
+
const app = apps.find((entry) => entry.id === id);
|
|
126
|
+
return app ? resolveAppIdentityPresentation(app, locale).name : id;
|
|
127
|
+
};
|
|
128
|
+
const policy = (await loadApprovalActionPolicies([row]))(row);
|
|
129
|
+
// Offering does not sign a decision; the issuer has no effect on truncation or eligibility.
|
|
130
|
+
const view = await appActionView(row, { issuer: "", appName, policy });
|
|
131
|
+
return appActionOffered(view, pendingApprovalCard(row, appName, policy), policy);
|
|
132
|
+
};
|
|
@@ -1,14 +1,18 @@
|
|
|
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";
|
|
15
|
+
export { type ApprovalActionPolicy, loadApprovalActionPolicies, pendingApprovalCard } from "./question";
|
|
12
16
|
|
|
13
17
|
// Derived from chat's AI_ACTION_BUDGET_MS and Studio's one-day abandonment of pending calls.
|
|
14
18
|
export const APPROVAL_MAX_WAIT_MS = 24 * 60 * 60_000;
|
|
@@ -82,10 +86,10 @@ export type ApprovalRequest = {
|
|
|
82
86
|
};
|
|
83
87
|
// SQL aliases keep the public row shape identical for reads and locked transaction reads. A function, because a
|
|
84
88
|
// fragment built at import time would bind the default sql handle before a test can choose its database.
|
|
85
|
-
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",
|
|
86
90
|
requester_kind AS "requesterKind", requester_ref AS "requesterRef", requester_app_id AS "requesterAppId",
|
|
87
91
|
requester_title AS "requesterTitle", subject_id AS "subjectId", approver_user_id AS "approverUserId",
|
|
88
|
-
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,
|
|
89
93
|
decided_device_id AS "decidedDeviceId", approver_auth_epoch::text AS "approverAuthEpoch",
|
|
90
94
|
decided_via AS "decidedVia", decided_at AS "decidedAt", consumed_at AS "consumedAt",
|
|
91
95
|
created_at AS "createdAt", expires_at AS "expiresAt"`;
|
|
@@ -121,7 +125,7 @@ export const request = async (input: ApprovalRequestInput, db?: SQL): Promise<Ap
|
|
|
121
125
|
requester_title, subject_id, approver_user_id, approver_auth_epoch, assurance, sentence, details, args_digest, review_digest, expires_at)
|
|
122
126
|
VALUES (${input.appId}, ${input.action}, ${input.approvalScope ?? null}, ${input.requester.kind}, ${input.requester.ref},
|
|
123
127
|
${input.requester.appId}, ${input.requester.title ?? null}, ${input.subjectId}::uuid, ${input.approverUserId}::uuid,
|
|
124
|
-
(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,
|
|
125
129
|
${approvalArgsDigest(input.input)}, ${approvalReviewDigest(input.review)}, now() + (${expiresInMs} * interval '1 millisecond'))
|
|
126
130
|
ON CONFLICT (requester_kind, requester_ref) DO NOTHING
|
|
127
131
|
RETURNING id
|
|
@@ -136,10 +140,15 @@ export const request = async (input: ApprovalRequestInput, db?: SQL): Promise<Ap
|
|
|
136
140
|
});
|
|
137
141
|
};
|
|
138
142
|
|
|
139
|
-
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> =>
|
|
140
144
|
transaction(db, async (tx) => {
|
|
141
|
-
const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests WHERE id=${id}::uuid`;
|
|
142
|
-
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;
|
|
143
152
|
});
|
|
144
153
|
|
|
145
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
|
-
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);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { listCapabilities } from "../../_internal/registry";
|
|
2
|
+
import { type AiStoredActionReview, storedActionReview } from "../../ai/action-review";
|
|
3
|
+
import type { ActionCard } from "../../ai/chat/action-card";
|
|
4
|
+
import { approvalCard } from "../../approvals/approval-card";
|
|
5
|
+
import {
|
|
6
|
+
type CapabilityActionPolicy,
|
|
7
|
+
CapabilityActionReviewPresentationSchema,
|
|
8
|
+
capabilityActionPolicy,
|
|
9
|
+
} from "../../contracts/capabilities";
|
|
10
|
+
import { DEFAULT_LOCALE } from "../../server/locale";
|
|
11
|
+
import type { PendingApprovalRow } from "./list";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One open approval as the question card words it, for places outside the browser: Cloud Login and notifications.
|
|
15
|
+
* The approvals page builds the same card from the same rows.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The declared policy of a row's Action, needed for new-shape reviews' device eligibility and fallback reason:
|
|
20
|
+
* `undefined` when none is needed or declared, `null` when the registry could not say.
|
|
21
|
+
*/
|
|
22
|
+
export type ApprovalActionPolicy = CapabilityActionPolicy | null | undefined;
|
|
23
|
+
const needsPolicy = (row: PendingApprovalRow): boolean => {
|
|
24
|
+
if (row.storedReview && "sentence" in row.storedReview) return true;
|
|
25
|
+
const review = CapabilityActionReviewPresentationSchema.safeParse(row.details);
|
|
26
|
+
return review.success && "sentence" in review.data;
|
|
27
|
+
};
|
|
28
|
+
/** Reads the registry once for the rows that need their Action's declared policy, as the approvals page does. */
|
|
29
|
+
export const loadApprovalActionPolicies = async (
|
|
30
|
+
rows: readonly PendingApprovalRow[],
|
|
31
|
+
): Promise<(row: PendingApprovalRow) => ApprovalActionPolicy> => {
|
|
32
|
+
if (!rows.some(needsPolicy)) return () => undefined;
|
|
33
|
+
const capabilities = await listCapabilities().catch(() => null);
|
|
34
|
+
return (row) => {
|
|
35
|
+
if (!needsPolicy(row)) return undefined;
|
|
36
|
+
if (capabilities === null) return null;
|
|
37
|
+
// An app that is not registered right now cannot say why its Action asks either.
|
|
38
|
+
const manifest = capabilities.find((entry) => entry.appId === row.appId)?.manifest;
|
|
39
|
+
return manifest ? capabilityActionPolicy(manifest, row.action) : null;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** The open request binds the content; the turn supplies the declared reason for older snapshots. */
|
|
44
|
+
export const pendingApprovalReview = (
|
|
45
|
+
row: Pick<PendingApprovalRow, "storedReview" | "details">,
|
|
46
|
+
policy?: ApprovalActionPolicy,
|
|
47
|
+
): AiStoredActionReview | null => {
|
|
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);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Resolve the recorded question in the language and time zone of its originating turn. */
|
|
58
|
+
export const pendingApprovalCard = (
|
|
59
|
+
row: PendingApprovalRow,
|
|
60
|
+
appName: (appId: string, locale: string) => string,
|
|
61
|
+
policy?: ApprovalActionPolicy,
|
|
62
|
+
fallbackLocale = DEFAULT_LOCALE,
|
|
63
|
+
): ActionCard => {
|
|
64
|
+
const locale = row.runConfig?.kind === "compact" ? fallbackLocale : (row.runConfig?.locale ?? fallbackLocale);
|
|
65
|
+
const timeZone = row.runConfig?.kind === "compact" ? "UTC" : (row.runConfig?.timeZone ?? "UTC");
|
|
66
|
+
const review = pendingApprovalReview(row, policy);
|
|
67
|
+
return approvalCard(
|
|
68
|
+
{ sentence: row.sentence, action: row.action, appName: appName(row.appId, locale), appIcon: null, review },
|
|
69
|
+
locale,
|
|
70
|
+
timeZone,
|
|
71
|
+
);
|
|
72
|
+
};
|
|
@@ -122,6 +122,7 @@ const SELF_SERVICE_ACTION_LABELS = {
|
|
|
122
122
|
"webauthn_credential.authenticate": "Passkey used",
|
|
123
123
|
"auth.pwa.device.enroll": "App paired",
|
|
124
124
|
"auth.pwa.device.revoke": "App removed",
|
|
125
|
+
"auth.session.sign_out_others": "Signed out everywhere else",
|
|
125
126
|
} as const satisfies Record<string, string>;
|
|
126
127
|
const SELF_SERVICE_ACTIONS = Object.keys(SELF_SERVICE_ACTION_LABELS);
|
|
127
128
|
|
|
@@ -4,6 +4,7 @@ import { isAccountCategoryAllowed } from "../account-category-policy";
|
|
|
4
4
|
import { accounts } from "../accounts";
|
|
5
5
|
import { logger } from "../logging";
|
|
6
6
|
import { providers } from "../providers";
|
|
7
|
+
import { session } from "../session";
|
|
7
8
|
|
|
8
9
|
type IpaLoginFailure =
|
|
9
10
|
| { ok: false; status: 403; reason: "category_disabled"; message: string }
|
|
@@ -19,6 +20,8 @@ type IpaLoginSuccess = {
|
|
|
19
20
|
ok: true;
|
|
20
21
|
userId: string;
|
|
21
22
|
user: User;
|
|
23
|
+
/** Read before the password check; session issuance refuses a newer one. */
|
|
24
|
+
authEpoch: number;
|
|
22
25
|
};
|
|
23
26
|
|
|
24
27
|
export type IpaLoginFlowResult = IpaLoginSuccess | IpaLoginFailure;
|
|
@@ -96,6 +99,13 @@ export const login = async (params: { username: string; password: string }): Pro
|
|
|
96
99
|
return failInvalidCredentials({ identifier: params.username, password: params.password });
|
|
97
100
|
}
|
|
98
101
|
|
|
102
|
+
// Read before the password check so a concurrent password change or sign-out ends this sign-in too.
|
|
103
|
+
const [account] = await sql<Array<{ auth_epoch: string | number | bigint }>>`
|
|
104
|
+
SELECT auth_epoch FROM auth.users WHERE provider = 'ipa' AND uid = ${uid}
|
|
105
|
+
`;
|
|
106
|
+
const authEpoch = account ? Number(account.auth_epoch) : null;
|
|
107
|
+
if (authEpoch !== null && (!Number.isSafeInteger(authEpoch) || authEpoch < 0)) throw new Error("User auth_epoch is invalid");
|
|
108
|
+
|
|
99
109
|
const loginResult = await providers.ipa.auth.login(uid, params.password).catch((error) => {
|
|
100
110
|
log.error("FreeIPA login transport failed", {
|
|
101
111
|
uid,
|
|
@@ -168,11 +178,21 @@ export const login = async (params: { username: string; password: string }): Pro
|
|
|
168
178
|
|
|
169
179
|
const userResult = await loadSyncedIpaUser(uid);
|
|
170
180
|
if (!userResult.ok) return userResult;
|
|
181
|
+
// An account synced in only after the read has no epoch this sign-in is bound to; the next attempt has one.
|
|
182
|
+
if (authEpoch === null) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
status: 400,
|
|
186
|
+
reason: "user_not_synced",
|
|
187
|
+
message: "Your account is not yet available. Please try again in a few minutes.",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
171
190
|
|
|
172
191
|
return {
|
|
173
192
|
ok: true,
|
|
174
193
|
userId: userResult.userId,
|
|
175
194
|
user: userResult.user,
|
|
195
|
+
authEpoch,
|
|
176
196
|
};
|
|
177
197
|
};
|
|
178
198
|
|
|
@@ -180,7 +200,11 @@ export const changeExpiredPassword = async (params: {
|
|
|
180
200
|
username: string;
|
|
181
201
|
currentPassword: string;
|
|
182
202
|
newPassword: string;
|
|
183
|
-
}): Promise<
|
|
203
|
+
}): Promise<
|
|
204
|
+
| (IpaLoginSuccess & { signOutOthersFailed: boolean })
|
|
205
|
+
| IpaLoginFailure
|
|
206
|
+
| { ok: false; status: number; reason: "change_failed"; message: string }
|
|
207
|
+
> => {
|
|
184
208
|
if (!(await isAccountCategoryAllowed({ provider: "ipa", profile: "user" })))
|
|
185
209
|
return { ok: false, status: 403, reason: "category_disabled", message: "FreeIPA accounts are disabled. Contact an administrator." };
|
|
186
210
|
const changeResult = await providers.ipa.auth.changeExpiredPassword(params);
|
|
@@ -193,5 +217,18 @@ export const changeExpiredPassword = async (params: {
|
|
|
193
217
|
};
|
|
194
218
|
}
|
|
195
219
|
|
|
196
|
-
|
|
220
|
+
// Resolve the local account here, before the caller issues its new browser session, even if login later fails.
|
|
221
|
+
const uid = await resolveIpaLoginUid(params.username);
|
|
222
|
+
const [user] = uid ? await sql<Array<{ id: string }>>`SELECT id FROM auth.users WHERE provider = 'ipa' AND uid = ${uid}` : [];
|
|
223
|
+
let signOutOthersFailed = false;
|
|
224
|
+
if (user) {
|
|
225
|
+
try {
|
|
226
|
+
await session.endOtherSignIns(user.id, { reason: "password_change" });
|
|
227
|
+
} catch {
|
|
228
|
+
log.warn("Password changed, but ending other sign-ins failed", { userId: user.id });
|
|
229
|
+
signOutOthersFailed = true;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const loginResult = await login({ username: params.username, password: params.newPassword });
|
|
233
|
+
return loginResult.ok ? { ...loginResult, signOutOthersFailed } : loginResult;
|
|
197
234
|
};
|