@k2b/cloud 0.29.1 → 0.31.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 +4 -2
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/app-roles.ts +14 -0
- package/src/_internal/capabilities.ts +113 -5
- package/src/_internal/capability-sentences.ts +413 -0
- package/src/_internal/define-app.ts +24 -5
- package/src/_internal/registry-validation.ts +5 -0
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/access/PermissionEditor.tsx +43 -24
- package/src/ai/app-skill-store.ts +298 -0
- package/src/ai/app-skills.ts +105 -0
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +68 -9
- package/src/ai/chart-block.ts +380 -0
- package/src/ai/chat/action-sentences.ts +49 -0
- package/src/ai/chat/blocks.tsx +97 -40
- package/src/ai/chat/chart-block.tsx +79 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +3 -1
- package/src/ai/chat/message-utils.ts +3 -0
- package/src/ai/chat/messages.ts +26 -2
- package/src/ai/chat/tool-groups.ts +66 -12
- package/src/ai/chat/tool-names.ts +6 -2
- package/src/ai/chat/turn-layout.ts +10 -2
- package/src/ai/chat/turn-view.tsx +123 -36
- package/src/ai/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/code-runtime-tools.ts +109 -61
- 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 +198 -0
- package/src/ai/default-tools.ts +25 -1
- package/src/ai/executor.ts +189 -35
- package/src/ai/fetch-file-tool.ts +41 -4
- package/src/ai/firecrawl-tools.ts +87 -4
- package/src/ai/index.ts +17 -1
- package/src/ai/migrate.ts +172 -14
- package/src/ai/protocol.ts +22 -1
- package/src/ai/routes.ts +11 -0
- package/src/ai/runtime-tools.ts +1 -0
- package/src/ai/runtime.ts +46 -7
- package/src/ai/skill-content.ts +54 -0
- package/src/ai/skills-routes.ts +22 -13
- package/src/ai/skills.ts +177 -231
- package/src/ai/store.ts +66 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tool-sentences.ts +38 -0
- package/src/ai/tools.ts +91 -3
- package/src/ai/types.ts +47 -1
- package/src/ai/web-provenance.ts +103 -0
- package/src/api/admin-ai-skills.ts +35 -16
- package/src/api/capabilities.ts +3 -2
- package/src/api/fanout.ts +128 -0
- package/src/api/help.ts +8 -3
- package/src/api/mcp.ts +4 -1
- package/src/api/search.ts +31 -106
- package/src/api/widgets.ts +291 -101
- package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
- package/src/browser/CloudResourceSearch.tsx +1 -1
- package/src/browser/command-shortcuts.ts +21 -6
- package/src/browser/ndjson.ts +34 -0
- package/src/browser/reload.ts +46 -0
- package/src/browser/search-stream.ts +4 -26
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +30 -0
- package/src/capabilities/executions.ts +3 -0
- package/src/capabilities/server.ts +20 -2
- package/src/cli/access.ts +48 -14
- package/src/cli/admin/ai-skills.ts +59 -39
- package/src/cli/commands.ts +20 -0
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/announcement-cookie.ts +62 -0
- package/src/contracts/announcements.ts +14 -52
- package/src/contracts/app.ts +34 -2
- package/src/contracts/capabilities.ts +24 -0
- package/src/contracts/pwa-paths.ts +7 -2
- package/src/contracts/pwa.ts +2 -7
- package/src/contracts/registry.ts +10 -1
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +67 -2
- package/src/desktop/solid.tsx +6 -7
- package/src/index.ts +1 -0
- package/src/server/help.ts +8 -1
- package/src/server/index.ts +1 -0
- package/src/server/widget-request.ts +27 -0
- package/src/services/accounts/users.ts +15 -0
- package/src/services/approvals/index.ts +225 -0
- package/src/services/approvals/migrate.ts +33 -0
- package/src/services/help/index.ts +18 -5
- package/src/services/help/types.ts +3 -2
- package/src/services/identity/constants.ts +2 -0
- package/src/services/identity/key-ring.ts +123 -42
- package/src/services/identity/runtime-config.ts +13 -2
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +43 -8
- package/src/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +12 -3
- package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
- package/src/ssr/GlobalSearchDialog.tsx +1 -43
- package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
- 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/LayoutHelpTrigger.island.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/PwaRuntime.island.tsx +1 -1
- package/src/ssr/app-navigation.ts +5 -7
- package/src/ssr/global-search-host.tsx +55 -0
- package/src/ssr/help.ts +3 -3
- 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 +216 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/ssr/rail-context.ts +4 -3
- package/src/ssr/runtime.ts +14 -1
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +35 -2
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
- package/src/ai/code-mode-skill.ts +0 -114
- package/src/ai/data-analysis-skill.ts +0 -10
- package/src/ai/grids-skill.ts +0 -158
- package/src/ai/skill-seeds.ts +0 -560
package/src/contracts/app.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Sync } from "@k2b/sync";
|
|
|
2
2
|
import type { PlatformPermission } from "./outgoing-mail";
|
|
3
3
|
import type { AppRegistryHelpSummary } from "./registry";
|
|
4
4
|
import type { Role } from "./shared";
|
|
5
|
-
import type { DashboardWidgetPresentation } from "./widgets";
|
|
5
|
+
import type { DashboardWidgetPresentation, DashboardWidgetSize } from "./widgets";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* One link entry contributed by an app to the global legal/info footer
|
|
@@ -69,6 +69,13 @@ export type AppPresentationTranslation = {
|
|
|
69
69
|
searchLinks?: Readonly<Record<string, string>>;
|
|
70
70
|
/** Search-link descriptions keyed by their stable same-origin href. */
|
|
71
71
|
searchLinkDescriptions?: Readonly<Record<string, string>>;
|
|
72
|
+
/** Dashboard widget titles and descriptions keyed by `widgets[].id`. */
|
|
73
|
+
widgets?: Readonly<Record<string, AppWidgetTranslation>>;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type AppWidgetTranslation = {
|
|
77
|
+
title?: string;
|
|
78
|
+
description?: string;
|
|
72
79
|
};
|
|
73
80
|
|
|
74
81
|
export type AppPresentationCatalog = {
|
|
@@ -161,7 +168,32 @@ export type WidgetEndpoint = {
|
|
|
161
168
|
id: string;
|
|
162
169
|
/** Absolute path on the app's HTTP service, e.g. "/api/accounts/widget/open-requests". */
|
|
163
170
|
path: string;
|
|
164
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* Name of the widget in the dashboard and its widget gallery, up to 80 characters, in the app's `baseLocale`;
|
|
173
|
+
* translate it with `presentation.translations[locale].widgets[id].title`. Defaults to the app's name.
|
|
174
|
+
*/
|
|
175
|
+
title?: string;
|
|
176
|
+
/** One sentence for the widget gallery, up to 200 characters; translated like `title`. Defaults to the app's description. */
|
|
177
|
+
description?: string;
|
|
178
|
+
/** Sizes the widget offers on the board. Defaults to `["large"]`; offer smaller sizes only when the handler fills them. */
|
|
179
|
+
sizes?: readonly DashboardWidgetSize[];
|
|
180
|
+
/** Size the widget starts in, one of `sizes`. Defaults to the largest offered size. */
|
|
181
|
+
defaultSize?: DashboardWidgetSize;
|
|
182
|
+
/**
|
|
183
|
+
* Put the widget on the default board of everyone who may see it and list it under "Suggested for you" in the
|
|
184
|
+
* gallery. Suggest only widgets that are useful without any setup.
|
|
185
|
+
*/
|
|
186
|
+
suggest?: boolean;
|
|
187
|
+
/**
|
|
188
|
+
* Show the widget in the gallery and on the default board only to people with one of these roles, like
|
|
189
|
+
* `nav.requiresRoles`. Visibility only: the handler still authorizes every request and answers `403`.
|
|
190
|
+
*/
|
|
191
|
+
requiresRoles?: readonly Role[];
|
|
192
|
+
/**
|
|
193
|
+
* @deprecated The dashboard has no zones or widths any more; declare `sizes` and `defaultSize` instead. Keep an
|
|
194
|
+
* existing value: the dashboard reads it only to convert a board saved before sizes existed into the one the person
|
|
195
|
+
* saw.
|
|
196
|
+
*/
|
|
165
197
|
presentation?: DashboardWidgetPresentation;
|
|
166
198
|
};
|
|
167
199
|
|
|
@@ -349,6 +349,26 @@ export type CapabilityUniversalSearchDefinition = {
|
|
|
349
349
|
|
|
350
350
|
export type CapabilitySchemaPresentation = Readonly<Record<string, string>>;
|
|
351
351
|
|
|
352
|
+
/**
|
|
353
|
+
* What one Action does, in words people read in approvals, receipts, and step rows. Each sentence is a
|
|
354
|
+
* template: `{input.to}` inserts a field of the validated call input by its dotted schema path, and
|
|
355
|
+
* `done` may also read the Action's result data with `{data.id}`. Cloud inserts values as escaped,
|
|
356
|
+
* bounded plain text, formats fields with a `date` or `date-time` schema format for the reader, and
|
|
357
|
+
* names an object by its email, address, or path before its display name. A sentence whose value is
|
|
358
|
+
* missing falls back to the generic wording, so every sentence stays optional. A `done` sentence
|
|
359
|
+
* replaces the result's runtime summary in the receipt.
|
|
360
|
+
*/
|
|
361
|
+
export type CapabilityActionSentences = {
|
|
362
|
+
/** What will happen, as a short instruction: "Send email to {input.to}". */
|
|
363
|
+
approval?: string;
|
|
364
|
+
/** What happened after the Action ran: "Sent email to {input.to}". */
|
|
365
|
+
done?: string;
|
|
366
|
+
/** What did not happen because the person rejected it: "Did not send email to {input.to}". */
|
|
367
|
+
rejected?: string;
|
|
368
|
+
/** What did not happen because the turn ended first: "Email to {input.to} not sent". */
|
|
369
|
+
notRun?: string;
|
|
370
|
+
};
|
|
371
|
+
|
|
352
372
|
export type CapabilityOperationPresentationTranslation = {
|
|
353
373
|
title?: string;
|
|
354
374
|
description?: string;
|
|
@@ -358,6 +378,8 @@ export type CapabilityOperationPresentationTranslation = {
|
|
|
358
378
|
data?: CapabilitySchemaPresentation;
|
|
359
379
|
/** Search-tag presentation keyed by the stable tag token. */
|
|
360
380
|
searchTags?: Readonly<Record<string, { title?: string; description?: string }>>;
|
|
381
|
+
/** Actions only: the complete set of sentences in this locale. */
|
|
382
|
+
sentences?: CapabilityActionSentences;
|
|
361
383
|
};
|
|
362
384
|
|
|
363
385
|
export type CapabilityPresentationTranslation = {
|
|
@@ -370,6 +392,8 @@ export type CapabilityPresentationTranslation = {
|
|
|
370
392
|
export type CapabilityPresentationCatalog = {
|
|
371
393
|
/** Locale of the complete Type, Query, Action, search-tag, and schema presentation. */
|
|
372
394
|
baseLocale: string;
|
|
395
|
+
/** Optional Action sentences in the base locale, keyed by Action local ID. */
|
|
396
|
+
sentences?: Readonly<Record<string, CapabilityActionSentences>>;
|
|
373
397
|
/** Partial presentation overlays with exact -> ancestor -> base fallback. */
|
|
374
398
|
translations: Readonly<Record<string, CapabilityPresentationTranslation>>;
|
|
375
399
|
};
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Paths of the installable mobile app, without schemas, so browser code that runs on every page can test for
|
|
3
|
-
* app page without loading the contract's validators.
|
|
2
|
+
* Paths and ids of the installable mobile app, without schemas, so browser code that runs on every page can test for
|
|
3
|
+
* an app page without loading the contract's validators.
|
|
4
4
|
*/
|
|
5
5
|
export const PWA_SCOPE = "/pwa/";
|
|
6
6
|
export const PWA_AUTH_PATH = "/pwa/_auth";
|
|
7
|
+
export const PWA_SERVICE_WORKER_PATH = "/pwa/sw.js";
|
|
8
|
+
/** Ids that can never own a part: the shell itself and its own pages `/pwa/settings` and `/pwa/offline`. */
|
|
9
|
+
export const PWA_RESERVED_IDS = ["pwa", "settings", "offline"] as const;
|
|
10
|
+
/** Whether the app with this id may have a part at `/pwa/<id>`: a plain app id that the shell does not reserve. */
|
|
11
|
+
export const isPwaPartId = (id: string): boolean => /^[a-z][a-z0-9-]*$/.test(id) && !PWA_RESERVED_IDS.some((reserved) => reserved === id);
|
package/src/contracts/pwa.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { PWA_AUTH_PATH, PWA_SCOPE } from "./pwa-paths";
|
|
2
|
+
import { isPwaPartId, PWA_AUTH_PATH, PWA_RESERVED_IDS, PWA_SCOPE, PWA_SERVICE_WORKER_PATH } from "./pwa-paths";
|
|
3
3
|
|
|
4
|
-
export { PWA_AUTH_PATH, PWA_SCOPE };
|
|
4
|
+
export { isPwaPartId, PWA_AUTH_PATH, PWA_RESERVED_IDS, PWA_SCOPE, PWA_SERVICE_WORKER_PATH };
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* The installable mobile app (preview). One shell application `pwa` owns the
|
|
@@ -11,11 +11,6 @@ export { PWA_AUTH_PATH, PWA_SCOPE };
|
|
|
11
11
|
export const PWA_SHELL_APP_ID = "pwa";
|
|
12
12
|
export const PWA_API_PATH = "/api/auth/pwa/v1";
|
|
13
13
|
export const PWA_MANIFEST_PATH = "/pwa/manifest.webmanifest";
|
|
14
|
-
export const PWA_SERVICE_WORKER_PATH = "/pwa/sw.js";
|
|
15
|
-
/** Ids that can never own a part: the shell itself and its own pages `/pwa/settings` and `/pwa/offline`. */
|
|
16
|
-
export const PWA_RESERVED_IDS = ["pwa", "settings", "offline"] as const;
|
|
17
|
-
/** Whether the app with this id may have a part at `/pwa/<id>`: a plain app id that the shell does not reserve. */
|
|
18
|
-
export const isPwaPartId = (id: string): boolean => /^[a-z][a-z0-9-]*$/.test(id) && !PWA_RESERVED_IDS.some((reserved) => reserved === id);
|
|
19
14
|
/**
|
|
20
15
|
* The page canvas of the app (`--k2b-surface-canvas` of `@k2b/ui`) in light and dark. App documents paint it
|
|
21
16
|
* before any stylesheet loads and give it to the status bar.
|
|
@@ -2,7 +2,7 @@ import type { AppAdminNavigationGroup, AppAppearance, AppAppearanceColor, AppPre
|
|
|
2
2
|
import type { CapabilityManifest, CapabilityPresentationCatalog } from "./capabilities";
|
|
3
3
|
import type { PlatformPermission } from "./outgoing-mail";
|
|
4
4
|
import type { Role } from "./shared";
|
|
5
|
-
import type { DashboardWidgetPresentation } from "./widgets";
|
|
5
|
+
import type { DashboardWidgetPresentation, DashboardWidgetSize } from "./widgets";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* App-registry entry type. Populated internally by `defineApp()` + the
|
|
@@ -52,10 +52,18 @@ export type AppRegistryLegalLink = {
|
|
|
52
52
|
icon?: string;
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
+
/** A widget declaration as the registry carries it; see `WidgetEndpoint`. Older apps send only `id` and `path`. */
|
|
55
56
|
export type AppRegistryWidget = {
|
|
56
57
|
id: string;
|
|
57
58
|
/** Absolute path on the app's HTTP service, e.g. "/api/quotes/widget/random". */
|
|
58
59
|
path: string;
|
|
60
|
+
title?: string;
|
|
61
|
+
description?: string;
|
|
62
|
+
sizes?: readonly DashboardWidgetSize[];
|
|
63
|
+
defaultSize?: DashboardWidgetSize;
|
|
64
|
+
suggest?: boolean;
|
|
65
|
+
requiresRoles?: readonly Role[];
|
|
66
|
+
/** @deprecated Read only to convert dashboard boards saved before sizes existed. */
|
|
59
67
|
presentation?: DashboardWidgetPresentation;
|
|
60
68
|
};
|
|
61
69
|
|
|
@@ -88,6 +96,7 @@ export type AppRegistryEntry = {
|
|
|
88
96
|
adminNav?: AppAdminNavigationGroup[];
|
|
89
97
|
capabilities?: AppRegistryCapabilitySummary;
|
|
90
98
|
help?: AppRegistryHelpSummary;
|
|
99
|
+
skills?: { manifestHash: string };
|
|
91
100
|
legalLinks?: AppRegistryLegalLink[];
|
|
92
101
|
searchLinks?: readonly AppSearchLink[];
|
|
93
102
|
/** The app's part of the mobile app (preview); `href` is `/pwa/<id>`. Older readers ignore it. */
|
package/src/contracts/shared.ts
CHANGED
|
@@ -329,6 +329,11 @@ export const AccessEntrySchema = z.object({
|
|
|
329
329
|
avatarHash: z.string().nullable().optional(),
|
|
330
330
|
/** Kind of a `service_account` principal; presentation only, never authorization. */
|
|
331
331
|
serviceAccountKind: ServiceAccountKindSchema.optional(),
|
|
332
|
+
/**
|
|
333
|
+
* A narrower part of the resource the grant covers, defined by the application, such as Mail's
|
|
334
|
+
* `assigned` conversations. Absent for a grant on the whole resource.
|
|
335
|
+
*/
|
|
336
|
+
scope: z.string().min(1).max(64).optional(),
|
|
332
337
|
});
|
|
333
338
|
export type AccessEntry = z.infer<typeof AccessEntrySchema>;
|
|
334
339
|
|
package/src/contracts/widgets.ts
CHANGED
|
@@ -38,18 +38,63 @@ const WidgetHrefSchema = z
|
|
|
38
38
|
export const WidgetToneSchema = z.enum(["emerald", "amber", "red", "blue", "zinc"]);
|
|
39
39
|
export type WidgetTone = z.infer<typeof WidgetToneSchema>;
|
|
40
40
|
|
|
41
|
+
/** @deprecated The dashboard no longer has zones; declare `sizes` and `defaultSize` instead. */
|
|
41
42
|
export type DashboardWidgetZone = "focus" | "overview" | "context";
|
|
43
|
+
/** @deprecated The dashboard no longer has spans; declare `sizes` and `defaultSize` instead. */
|
|
42
44
|
export type DashboardWidgetSpan = "standard" | "wide";
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
+
* @deprecated The dashboard has no zones or widths any more. Declare `sizes` and `defaultSize` on the widget instead;
|
|
48
|
+
* the dashboard reads this recommendation only to convert a board saved before sizes existed.
|
|
47
49
|
*/
|
|
48
50
|
export type DashboardWidgetPresentation = {
|
|
49
51
|
defaultZone?: DashboardWidgetZone;
|
|
50
52
|
defaultSpan?: DashboardWidgetSpan;
|
|
51
53
|
};
|
|
52
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The three sizes a widget can take on the dashboard board, which has four columns on wider screens and two on
|
|
57
|
+
* phones: `small` is one column and one row, `medium` two columns and one row, and `large` two columns and two rows.
|
|
58
|
+
* On a phone, `small` takes half the width and `medium` and `large` the full width.
|
|
59
|
+
*/
|
|
60
|
+
export const DASHBOARD_WIDGET_SIZES = ["small", "medium", "large"] as const;
|
|
61
|
+
export type DashboardWidgetSize = (typeof DASHBOARD_WIDGET_SIZES)[number];
|
|
62
|
+
|
|
63
|
+
/** Longest declared widget title, in characters, the same bound as a shortcut title. */
|
|
64
|
+
export const WIDGET_TITLE_MAX_LENGTH = 80;
|
|
65
|
+
/** Longest declared widget description, in characters: one sentence in the widget gallery. */
|
|
66
|
+
export const WIDGET_DESCRIPTION_MAX_LENGTH = 200;
|
|
67
|
+
|
|
68
|
+
export const isDashboardWidgetSize = (value: unknown): value is DashboardWidgetSize =>
|
|
69
|
+
DASHBOARD_WIDGET_SIZES.some((size) => size === value);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The sizes a widget offers and the size it starts with. A widget that declares no valid size offers only `large`,
|
|
73
|
+
* the height every widget had before sizes existed; without a valid `defaultSize`, it starts at its largest size.
|
|
74
|
+
*/
|
|
75
|
+
export const resolveWidgetSizes = (declaration: {
|
|
76
|
+
sizes?: readonly unknown[];
|
|
77
|
+
defaultSize?: unknown;
|
|
78
|
+
}): { sizes: DashboardWidgetSize[]; defaultSize: DashboardWidgetSize } => {
|
|
79
|
+
const declared = DASHBOARD_WIDGET_SIZES.filter((size) => declaration.sizes?.includes(size));
|
|
80
|
+
const sizes: DashboardWidgetSize[] = declared.length > 0 ? declared : ["large"];
|
|
81
|
+
const defaultSize =
|
|
82
|
+
isDashboardWidgetSize(declaration.defaultSize) && sizes.includes(declaration.defaultSize) ? declaration.defaultSize : sizes.at(-1)!;
|
|
83
|
+
return { sizes, defaultSize };
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** `requested` when the widget offers it, otherwise the widget's default size. */
|
|
87
|
+
export const fitWidgetSize = (
|
|
88
|
+
requested: unknown,
|
|
89
|
+
widget: { sizes: readonly DashboardWidgetSize[]; defaultSize: DashboardWidgetSize },
|
|
90
|
+
): DashboardWidgetSize => (isDashboardWidgetSize(requested) && widget.sizes.includes(requested) ? requested : widget.defaultSize);
|
|
91
|
+
|
|
92
|
+
/** What the dashboard asks a widget handler for, read with `getWidgetRequest(c)`. */
|
|
93
|
+
export type WidgetRequest = {
|
|
94
|
+
/** The size the widget is shown in, always one the widget declares. */
|
|
95
|
+
size: DashboardWidgetSize;
|
|
96
|
+
};
|
|
97
|
+
|
|
53
98
|
export const WidgetAccentSchema = z
|
|
54
99
|
.object({
|
|
55
100
|
tone: WidgetToneSchema,
|
|
@@ -181,3 +226,23 @@ export const WidgetResponseSchema = z
|
|
|
181
226
|
})
|
|
182
227
|
.strip();
|
|
183
228
|
export type WidgetResponse = z.infer<typeof WidgetResponseSchema>;
|
|
229
|
+
|
|
230
|
+
/** A widget's stable key on the dashboard and in the widget stream: `<appId>/<widgetId>`. */
|
|
231
|
+
export const widgetKey = (appId: string, widgetId: string): string => `${appId}/${widgetId}`;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* How one widget answered in the widget stream: `ok` carries its response, `empty` is a `204`, `forbidden` a `403`,
|
|
235
|
+
* `timeout` means it did not answer within its own budget, and `error` covers every other failure.
|
|
236
|
+
*/
|
|
237
|
+
export type WidgetStreamStatus = "ok" | "empty" | "forbidden" | "timeout" | "error";
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* One line of `GET /api/widgets/v1` (`application/x-ndjson`): `start` names the widgets Core asks, in registry
|
|
241
|
+
* order; one `widget` line follows per widget in the order they finish; `done` closes the stream. Every widget named
|
|
242
|
+
* in `start` gets exactly one line.
|
|
243
|
+
*/
|
|
244
|
+
export type WidgetStreamLine =
|
|
245
|
+
| { type: "start"; widgets: string[] }
|
|
246
|
+
| { type: "widget"; key: string; status: "ok"; widget: WidgetResponse; ms: number }
|
|
247
|
+
| { type: "widget"; key: string; status: Exclude<WidgetStreamStatus, "ok">; ms: number }
|
|
248
|
+
| { type: "done"; status: "complete" | "partial" };
|
package/src/desktop/solid.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { suppressTextSelection } from "@k2b/ui";
|
|
1
2
|
import { deserialize as deserializeProps, serialize as serializeProps } from "seroval";
|
|
2
3
|
import type { Component, JSX, ParentProps } from "solid-js";
|
|
3
4
|
import { children, createContext, createMemo, createSignal, For, onCleanup, onMount, Show, useContext } from "solid-js";
|
|
@@ -483,10 +484,11 @@ const startDesktopResize = (event: PointerEvent, onMove: (dx: number, dy: number
|
|
|
483
484
|
const startX = event.clientX;
|
|
484
485
|
const startY = event.clientY;
|
|
485
486
|
const move = (next: PointerEvent) => onMove(next.clientX - startX, next.clientY - startY);
|
|
487
|
+
const releaseTextSelection = suppressTextSelection(event.pointerId);
|
|
486
488
|
const stop = () => {
|
|
487
489
|
window.removeEventListener("pointermove", move);
|
|
488
490
|
window.removeEventListener("pointerup", stop);
|
|
489
|
-
|
|
491
|
+
releaseTextSelection();
|
|
490
492
|
document.body.style.cursor = "";
|
|
491
493
|
};
|
|
492
494
|
window.addEventListener("pointermove", move);
|
|
@@ -631,10 +633,9 @@ export const DesktopWorkspace = ((props: DesktopWorkspaceProps) => {
|
|
|
631
633
|
|
|
632
634
|
const resizeSidebar = (event: PointerEvent) => {
|
|
633
635
|
const pane = sidebar();
|
|
634
|
-
if (!pane?.resizable) return;
|
|
636
|
+
if (!pane?.resizable || event.button !== 0) return;
|
|
635
637
|
const start = sidebarOpen() ? sidebarSize() : sidebarColumnSize();
|
|
636
638
|
document.body.style.cursor = "col-resize";
|
|
637
|
-
document.body.style.userSelect = "none";
|
|
638
639
|
startDesktopResize(event, (dx) => {
|
|
639
640
|
const raw = start + dx;
|
|
640
641
|
if (shouldRail(pane, raw)) {
|
|
@@ -650,10 +651,9 @@ export const DesktopWorkspace = ((props: DesktopWorkspaceProps) => {
|
|
|
650
651
|
|
|
651
652
|
const resizeRight = (event: PointerEvent) => {
|
|
652
653
|
const pane = right();
|
|
653
|
-
if (!pane?.resizable) return;
|
|
654
|
+
if (!pane?.resizable || event.button !== 0) return;
|
|
654
655
|
const start = rightOpen() ? rightSize() : rightColumnSize();
|
|
655
656
|
document.body.style.cursor = "col-resize";
|
|
656
|
-
document.body.style.userSelect = "none";
|
|
657
657
|
startDesktopResize(event, (dx) => {
|
|
658
658
|
const raw = start - dx;
|
|
659
659
|
if (shouldRail(pane, raw)) {
|
|
@@ -669,10 +669,9 @@ export const DesktopWorkspace = ((props: DesktopWorkspaceProps) => {
|
|
|
669
669
|
|
|
670
670
|
const resizeBottom = (event: PointerEvent) => {
|
|
671
671
|
const pane = bottom();
|
|
672
|
-
if (!pane?.resizable) return;
|
|
672
|
+
if (!pane?.resizable || event.button !== 0) return;
|
|
673
673
|
const start = bottomOpen() ? bottomSize() : bottomRowSize();
|
|
674
674
|
document.body.style.cursor = "row-resize";
|
|
675
|
-
document.body.style.userSelect = "none";
|
|
676
675
|
startDesktopResize(event, (_dx, dy) => {
|
|
677
676
|
const raw = start - dy;
|
|
678
677
|
if (shouldRail(pane, raw)) {
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,7 @@ export {
|
|
|
35
35
|
export type { RuntimeCompatibilityIssue } from "./_internal/runtime-compatibility";
|
|
36
36
|
export { assessRuntimeCompatibility } from "./_internal/runtime-compatibility";
|
|
37
37
|
export { buildRuntimeFromRegistry } from "./_internal/runtime-context";
|
|
38
|
+
export { type AppSkillDefinition, skill } from "./ai/app-skills";
|
|
38
39
|
export { defineCapabilities } from "./contracts/capabilities";
|
|
39
40
|
export type {
|
|
40
41
|
AnyBoundNotificationDefinition,
|
package/src/server/help.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { parse as parseYaml } from "yaml";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { type HelpDocumentManifest, type HelpDocumentPayload, type HelpSearchPayload, helpLocaleChain } from "../shared/help";
|
|
5
5
|
import { markdownToPlainText, renderHelpMarkdown } from "../shared/markdown";
|
|
6
|
+
import { auth } from "./middleware/auth";
|
|
6
7
|
|
|
7
8
|
const metadataSchema = z.object({
|
|
8
9
|
id: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
@@ -133,6 +134,9 @@ export const defineHelp = (
|
|
|
133
134
|
* Define one app-owned help corpus. The explicit source list is deliberate:
|
|
134
135
|
* IDs, ordering and ownership stay visible in code; no filesystem scanning or
|
|
135
136
|
* build-time convention is required.
|
|
137
|
+
*
|
|
138
|
+
* @deprecated Declare Help with `defineHelp()` and pass it to `app.start({ help })`. This router only
|
|
139
|
+
* requires sign-in; it does not apply the app's Help visibility, so the mounting app's routes must guard it.
|
|
136
140
|
*/
|
|
137
141
|
export const defineHelpCollection = (options: { basePath: string; sources: readonly string[] }): HelpCollection => {
|
|
138
142
|
const basePath = options.basePath.replace(/\/$/, "");
|
|
@@ -151,7 +155,10 @@ export const defineHelpCollection = (options: { basePath: string; sources: reado
|
|
|
151
155
|
url: `${basePath}/${encodeURIComponent(id)}`,
|
|
152
156
|
}));
|
|
153
157
|
|
|
154
|
-
|
|
158
|
+
// Help is for signed-in people wherever an app mounts this router; its own route policy may narrow it further.
|
|
159
|
+
const router = new Hono();
|
|
160
|
+
router.use(auth.requireRole("authenticated"), auth.requireUser());
|
|
161
|
+
router
|
|
155
162
|
.get("/search", (context) => {
|
|
156
163
|
const query = context.req.query("q")?.trim().toLocaleLowerCase().slice(0, 200) ?? "";
|
|
157
164
|
const payload: HelpSearchPayload = {
|
package/src/server/index.ts
CHANGED
|
@@ -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" };
|
|
@@ -223,6 +223,21 @@ export const getMinimal = async (params: { id: string } | { uid: string }): Prom
|
|
|
223
223
|
return buildUserMutationTarget(rows[0]!);
|
|
224
224
|
};
|
|
225
225
|
|
|
226
|
+
/** Audience roles for a resolved access subject, including delegated users and group managers. */
|
|
227
|
+
export const getRoles = async (userId: string): Promise<Role[]> => {
|
|
228
|
+
const { groupsAdmin } = await getFreeIpaConfig();
|
|
229
|
+
const [row] = await sql<DbRow[]>`SELECT u.provider, u.profile,
|
|
230
|
+
CASE WHEN u.provider='local' THEN u.admin ELSE EXISTS (
|
|
231
|
+
SELECT 1 FROM auth.ipa_user_effective_groups eg WHERE eg.user_id=u.id
|
|
232
|
+
AND eg.group_name=ANY(${toPgTextArray(groupsAdmin)}::text[])
|
|
233
|
+
) END AS effective_admin,
|
|
234
|
+
ARRAY(${managedGroupsNamesSubquery(sql`u.id`)}) AS manages
|
|
235
|
+
FROM auth.users u WHERE u.id=${userId}::uuid`;
|
|
236
|
+
if (!row) return [];
|
|
237
|
+
const { provider, profile } = resolveProviderProfile(row);
|
|
238
|
+
return buildRoles({ provider, profile, memberofGroup: [], manages: stringArray(row.manages), admin: Boolean(row.effective_admin) });
|
|
239
|
+
};
|
|
240
|
+
|
|
226
241
|
export const getByUid = async (params: { uid: string }): Promise<{ id: string; roles: Role[] } | null> => {
|
|
227
242
|
const { groupsAdmin } = await getFreeIpaConfig();
|
|
228
243
|
const rows = await sql<DbRow[]>`
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { type SQL, sql } from "bun";
|
|
2
|
+
import { capabilityRequestHash } from "../../capabilities/claims";
|
|
3
|
+
import type { CapabilityActionReview } from "../../contracts/capabilities";
|
|
4
|
+
import { isAppSessionActor } from "../../server/actor";
|
|
5
|
+
import type { RequestActor, RequestCredentialKind } from "../../server/middleware/auth";
|
|
6
|
+
import { audit } from "../audit";
|
|
7
|
+
import { toPgTextArray } from "../postgres";
|
|
8
|
+
|
|
9
|
+
export { migrateApprovals } from "./migrate";
|
|
10
|
+
|
|
11
|
+
// Derived from chat's AI_ACTION_BUDGET_MS and Studio's one-day abandonment of pending calls.
|
|
12
|
+
export const APPROVAL_MAX_WAIT_MS = 24 * 60 * 60_000;
|
|
13
|
+
export type ApprovalDecisionChannel = "web_session" | "app_session" | "oauth" | "api_key" | "invocation";
|
|
14
|
+
export type ApprovalRequesterKind = "assistant_turn" | "studio_app";
|
|
15
|
+
export type ApprovalStatus = "pending" | "approved" | "rejected" | "expired" | "cancelled" | "consumed";
|
|
16
|
+
export type ApprovalAssurance = "standard" | "confirm";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `standard` when the person may remember the approval of this call: an Action with `approval: "rememberable"` whose
|
|
20
|
+
* review names a scope for these arguments, a tool whose policy allows remembering, or a website. Otherwise `confirm`.
|
|
21
|
+
*/
|
|
22
|
+
export const approvalAssurance = (rememberable: boolean): ApprovalAssurance => (rememberable ? "standard" : "confirm");
|
|
23
|
+
export const approvalArgsDigest = (input: unknown): string => capabilityRequestHash(input);
|
|
24
|
+
export const approvalReviewDigest = (review: CapabilityActionReview | null | undefined): string => capabilityRequestHash(review ?? null);
|
|
25
|
+
export const approvalDecisionChannel = (input: {
|
|
26
|
+
credentialKind?: RequestCredentialKind;
|
|
27
|
+
actor?: RequestActor;
|
|
28
|
+
}): ApprovalDecisionChannel =>
|
|
29
|
+
input.credentialKind === "session"
|
|
30
|
+
? isAppSessionActor(input.actor)
|
|
31
|
+
? "app_session"
|
|
32
|
+
: "web_session"
|
|
33
|
+
: (input.credentialKind ?? "invocation");
|
|
34
|
+
export const approvalScopeFromToolName = (name: string): { appId: string; action: string } => {
|
|
35
|
+
const dot = name.indexOf(".");
|
|
36
|
+
return dot < 0 ? { appId: "assistant", action: name } : { appId: name.slice(0, dot), action: name.slice(dot + 1) };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type ApprovalRequestInput = {
|
|
40
|
+
appId: string;
|
|
41
|
+
action: string;
|
|
42
|
+
approvalScope?: string | null;
|
|
43
|
+
requester: { kind: ApprovalRequesterKind; ref: string; appId: string; title?: string | null };
|
|
44
|
+
subjectId: string;
|
|
45
|
+
approverUserId: string;
|
|
46
|
+
assurance: ApprovalAssurance;
|
|
47
|
+
sentence: string;
|
|
48
|
+
review?: CapabilityActionReview | null;
|
|
49
|
+
input: unknown;
|
|
50
|
+
expiresInMs: number;
|
|
51
|
+
};
|
|
52
|
+
export type ApprovalRequest = {
|
|
53
|
+
id: string;
|
|
54
|
+
appId: string;
|
|
55
|
+
action: string;
|
|
56
|
+
approvalScope: string | null;
|
|
57
|
+
requesterKind: ApprovalRequesterKind;
|
|
58
|
+
requesterRef: string;
|
|
59
|
+
requesterAppId: string;
|
|
60
|
+
requesterTitle: string | null;
|
|
61
|
+
subjectId: string;
|
|
62
|
+
approverUserId: string;
|
|
63
|
+
assurance: ApprovalAssurance;
|
|
64
|
+
sentence: string;
|
|
65
|
+
details: CapabilityActionReview | null;
|
|
66
|
+
argsDigest: string;
|
|
67
|
+
reviewDigest: string;
|
|
68
|
+
status: ApprovalStatus;
|
|
69
|
+
decidedVia: ApprovalDecisionChannel | null;
|
|
70
|
+
decidedAt: Date | null;
|
|
71
|
+
consumedAt: Date | null;
|
|
72
|
+
createdAt: Date;
|
|
73
|
+
expiresAt: Date;
|
|
74
|
+
};
|
|
75
|
+
// SQL aliases keep the public row shape identical for reads and locked transaction reads. A function, because a
|
|
76
|
+
// fragment built at import time would bind the default sql handle before a test can choose its database.
|
|
77
|
+
const columns = () => sql`id, app_id AS "appId", action, approval_scope AS "approvalScope",
|
|
78
|
+
requester_kind AS "requesterKind", requester_ref AS "requesterRef", requester_app_id AS "requesterAppId",
|
|
79
|
+
requester_title AS "requesterTitle", subject_id AS "subjectId", approver_user_id AS "approverUserId",
|
|
80
|
+
assurance, sentence, details, args_digest AS "argsDigest", review_digest AS "reviewDigest", status,
|
|
81
|
+
decided_via AS "decidedVia", decided_at AS "decidedAt", consumed_at AS "consumedAt",
|
|
82
|
+
created_at AS "createdAt", expires_at AS "expiresAt"`;
|
|
83
|
+
|
|
84
|
+
const transaction = <T>(db: SQL | undefined, run: (tx: SQL) => Promise<T>): Promise<T> => (db && db !== sql ? run(db) : sql.begin(run));
|
|
85
|
+
|
|
86
|
+
export type ApprovalRequestResult = { ok: true; id: string } | { ok: false; code: "CONFLICT" };
|
|
87
|
+
/**
|
|
88
|
+
* Records the question once per requester reference. Asking again, for example after a worker restart, returns the
|
|
89
|
+
* open request unchanged: its snapshot is what the person may already see, so a changed effect fails `consume` and
|
|
90
|
+
* asks again instead of rebinding that decision. A reference whose request is decided, expired, or another person's
|
|
91
|
+
* is a conflict.
|
|
92
|
+
*/
|
|
93
|
+
export const request = async (input: ApprovalRequestInput, db?: SQL): Promise<ApprovalRequestResult> => {
|
|
94
|
+
// Zero or less records a request that is already expired, such as a call that waited too long before the upgrade.
|
|
95
|
+
const expiresInMs = Number.isNaN(input.expiresInMs)
|
|
96
|
+
? APPROVAL_MAX_WAIT_MS
|
|
97
|
+
: Math.max(0, Math.min(input.expiresInMs, APPROVAL_MAX_WAIT_MS));
|
|
98
|
+
return transaction(db, async (tx): Promise<ApprovalRequestResult> => {
|
|
99
|
+
const [row] = await tx<{ id: string }[]>`
|
|
100
|
+
INSERT INTO approvals.requests (app_id, action, approval_scope, requester_kind, requester_ref, requester_app_id,
|
|
101
|
+
requester_title, subject_id, approver_user_id, assurance, sentence, details, args_digest, review_digest, expires_at)
|
|
102
|
+
VALUES (${input.appId}, ${input.action}, ${input.approvalScope ?? null}, ${input.requester.kind}, ${input.requester.ref},
|
|
103
|
+
${input.requester.appId}, ${input.requester.title ?? null}, ${input.subjectId}::uuid, ${input.approverUserId}::uuid,
|
|
104
|
+
${input.assurance}, ${input.sentence}, ${JSON.stringify(input.review ?? null)}::text::jsonb,
|
|
105
|
+
${approvalArgsDigest(input.input)}, ${approvalReviewDigest(input.review)}, now() + (${expiresInMs} * interval '1 millisecond'))
|
|
106
|
+
ON CONFLICT (requester_kind, requester_ref) DO NOTHING
|
|
107
|
+
RETURNING id
|
|
108
|
+
`;
|
|
109
|
+
if (row) return { ok: true, id: row.id };
|
|
110
|
+
const [open] = await tx<{ id: string }[]>`SELECT id FROM approvals.requests
|
|
111
|
+
WHERE requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref}
|
|
112
|
+
AND status='pending' AND expires_at > now() AND subject_id=${input.subjectId}::uuid
|
|
113
|
+
AND approver_user_id=${input.approverUserId}::uuid AND app_id=${input.appId} AND action=${input.action}`;
|
|
114
|
+
return open ? { ok: true, id: open.id } : { ok: false, code: "CONFLICT" };
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const get = async (id: string, db?: SQL): Promise<ApprovalRequest | null> =>
|
|
119
|
+
transaction(db, async (tx) => {
|
|
120
|
+
const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests WHERE id=${id}::uuid`;
|
|
121
|
+
return row ?? null;
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export type ApprovalDecisionResult =
|
|
125
|
+
| { ok: true; status: ApprovalStatus; replay: boolean }
|
|
126
|
+
| { ok: false; code: "EXPIRED" | "NOT_FOUND" }
|
|
127
|
+
| { ok: false; code: "ALREADY_DECIDED"; status: ApprovalStatus };
|
|
128
|
+
export const decide = async (
|
|
129
|
+
id: string,
|
|
130
|
+
decision: { approved: boolean },
|
|
131
|
+
actor: { userId: string; via: ApprovalDecisionChannel },
|
|
132
|
+
db?: SQL,
|
|
133
|
+
): Promise<ApprovalDecisionResult> => {
|
|
134
|
+
const result = await transaction(db, async (tx): Promise<ApprovalDecisionResult> => {
|
|
135
|
+
const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests
|
|
136
|
+
WHERE id=${id}::uuid AND approver_user_id=${actor.userId}::uuid FOR UPDATE`;
|
|
137
|
+
if (!row) return { ok: false, code: "NOT_FOUND" };
|
|
138
|
+
const status = decision.approved ? "approved" : "rejected";
|
|
139
|
+
const [won] = await tx<ApprovalRequest[]>`
|
|
140
|
+
UPDATE approvals.requests SET status=${status}, decided_via=${actor.via}, decided_at=now()
|
|
141
|
+
WHERE id=${id}::uuid AND approver_user_id=${actor.userId}::uuid AND status='pending' AND expires_at > now()
|
|
142
|
+
RETURNING ${columns()}
|
|
143
|
+
`;
|
|
144
|
+
if (won) {
|
|
145
|
+
await audit.record(
|
|
146
|
+
{
|
|
147
|
+
action: decision.approved ? "auth.action.approve" : "auth.action.deny",
|
|
148
|
+
outcome: "allowed",
|
|
149
|
+
actor: { userId: actor.userId },
|
|
150
|
+
target: { type: "approval_request", id, label: `${won.appId}.${won.action}` },
|
|
151
|
+
metadata: {
|
|
152
|
+
appId: won.appId,
|
|
153
|
+
action: won.action,
|
|
154
|
+
assurance: won.assurance,
|
|
155
|
+
decidedVia: actor.via,
|
|
156
|
+
requesterKind: won.requesterKind,
|
|
157
|
+
requesterAppId: won.requesterAppId,
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
tx,
|
|
161
|
+
);
|
|
162
|
+
return { ok: true, status, replay: false };
|
|
163
|
+
}
|
|
164
|
+
if (row.status === "pending") {
|
|
165
|
+
await tx`UPDATE approvals.requests SET status='expired' WHERE id=${id}::uuid AND status='pending' AND expires_at <= now()`;
|
|
166
|
+
return { ok: false, code: "EXPIRED" };
|
|
167
|
+
}
|
|
168
|
+
if (
|
|
169
|
+
(decision.approved && (row.status === "approved" || row.status === "consumed")) ||
|
|
170
|
+
(!decision.approved && row.status === "rejected")
|
|
171
|
+
)
|
|
172
|
+
return { ok: true, status: row.status, replay: true };
|
|
173
|
+
return { ok: false, code: "ALREADY_DECIDED", status: row.status };
|
|
174
|
+
});
|
|
175
|
+
// Publish a later NATS decision hint only after the owning transaction commits.
|
|
176
|
+
return result;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export type ApprovalConsumeResult =
|
|
180
|
+
| { ok: true }
|
|
181
|
+
| { ok: false; code: "NOT_FOUND" | "NOT_APPROVED" | "ALREADY_CONSUMED" | "INPUT_CHANGED" | "REVIEW_CHANGED" };
|
|
182
|
+
export const consume = async (
|
|
183
|
+
id: string,
|
|
184
|
+
input: { input: unknown; review: CapabilityActionReview | null; requester: { kind: ApprovalRequesterKind; ref: string } },
|
|
185
|
+
db?: SQL,
|
|
186
|
+
): Promise<ApprovalConsumeResult> => {
|
|
187
|
+
const argsDigest = approvalArgsDigest(input.input),
|
|
188
|
+
reviewDigest = approvalReviewDigest(input.review);
|
|
189
|
+
return transaction(db, async (tx): Promise<ApprovalConsumeResult> => {
|
|
190
|
+
const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests
|
|
191
|
+
WHERE id=${id}::uuid AND requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref} FOR UPDATE`;
|
|
192
|
+
if (!row) return { ok: false, code: "NOT_FOUND" };
|
|
193
|
+
if (row.status === "consumed") return { ok: false, code: "ALREADY_CONSUMED" };
|
|
194
|
+
if (row.status !== "approved") return { ok: false, code: "NOT_APPROVED" };
|
|
195
|
+
if (row.argsDigest !== argsDigest || row.reviewDigest !== reviewDigest) {
|
|
196
|
+
await tx`UPDATE approvals.requests SET status='cancelled' WHERE id=${id}::uuid AND status='approved'`;
|
|
197
|
+
return { ok: false, code: row.argsDigest !== argsDigest ? "INPUT_CHANGED" : "REVIEW_CHANGED" };
|
|
198
|
+
}
|
|
199
|
+
await tx`UPDATE approvals.requests SET status='consumed', consumed_at=now() WHERE id=${id}::uuid AND status='approved'
|
|
200
|
+
AND requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref}
|
|
201
|
+
AND args_digest=${argsDigest} AND review_digest=${reviewDigest}`;
|
|
202
|
+
return { ok: true };
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export const cancel = async (ids: string[], db?: SQL): Promise<void> => {
|
|
207
|
+
if (!ids.length) return;
|
|
208
|
+
await transaction(db, async (tx) => {
|
|
209
|
+
await tx`UPDATE approvals.requests SET status='cancelled' WHERE id=ANY(${toPgTextArray(ids)}::uuid[]) AND status IN ('pending','approved')`;
|
|
210
|
+
});
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export const pruneApprovalRequests = async (before: Date): Promise<number> => {
|
|
214
|
+
let total = 0;
|
|
215
|
+
let deleted: number;
|
|
216
|
+
do {
|
|
217
|
+
const result = await sql`WITH expired AS (SELECT id FROM approvals.requests WHERE expires_at < ${before} LIMIT 5000)
|
|
218
|
+
DELETE FROM approvals.requests request USING expired WHERE request.id=expired.id`;
|
|
219
|
+
deleted = result.count;
|
|
220
|
+
total += deleted;
|
|
221
|
+
} while (deleted === 5000);
|
|
222
|
+
return total;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
export const approvals = { request, get, decide, consume, cancel };
|