@k2b/cloud 0.38.0 → 0.40.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/README.md +8 -0
- package/package.json +12 -5
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-favicon.ts +4 -2
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/capabilities.ts +72 -48
- package/src/_internal/capability-reviews.ts +81 -0
- package/src/_internal/capability-schemas.ts +29 -0
- package/src/_internal/capability-sentences.ts +216 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +63 -16
- package/src/_internal/font-preloads.ts +2 -1
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/_internal/runtime-watcher.ts +6 -0
- package/src/_internal/static-assets.ts +52 -3
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +32 -7
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/account/EntitySearch.tsx +1 -1
- package/src/ai/action-review.ts +101 -0
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/background-review.ts +72 -0
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +29 -11
- package/src/ai/capability-execution.ts +76 -43
- package/src/ai/capability-result.ts +95 -0
- package/src/ai/chat/action-card.ts +398 -0
- package/src/ai/chat/action-messages.ts +129 -0
- package/src/ai/chat/action-question.browser-harness.tsx +245 -0
- package/src/ai/chat/action-question.fixtures.ts +733 -0
- package/src/ai/chat/action-question.tsx +669 -0
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action.tsx +481 -0
- package/src/ai/chat/turn-view.tsx +45 -163
- package/src/ai/chat/view-block.tsx +22 -7
- package/src/ai/chat-task-contracts.ts +31 -3
- package/src/ai/chat-task-reasons-migrate.ts +75 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +438 -78
- package/src/ai/client/controller.ts +59 -0
- package/src/ai/default-tools.ts +9 -1
- package/src/ai/executor.ts +190 -39
- package/src/ai/index.ts +14 -0
- package/src/ai/migrate.ts +39 -4
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +172 -2
- package/src/ai/runtime.ts +13 -27
- package/src/ai/skills-routes.ts +3 -0
- package/src/ai/skills.ts +10 -1
- package/src/ai/store.ts +232 -27
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-wording.ts +67 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +40 -4
- package/src/api/accounts-entities.ts +2 -1
- package/src/api/admin-ai-projects.ts +2 -0
- package/src/api/admin-ai-skills.ts +2 -0
- package/src/api/admin-ai-usage.ts +2 -0
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-geoip.ts +80 -0
- package/src/api/admin-identity.ts +3 -0
- package/src/api/admin-lifecycle.ts +3 -0
- package/src/api/admin-outgoing-mail.ts +6 -2
- package/src/api/app-approval.ts +54 -6
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +3 -1
- package/src/api/auth.ts +25 -6
- package/src/api/capabilities.ts +44 -11
- package/src/api/capability-streams.ts +12 -0
- package/src/api/help.ts +4 -28
- package/src/api/index.ts +7 -1
- package/src/api/lasting-access-messages.ts +17 -0
- package/src/api/mcp.ts +3 -0
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +80 -33
- package/src/api/search.ts +3 -1
- package/src/api/sync-ops.ts +2 -0
- package/src/api/widgets.ts +3 -0
- package/src/approvals/Approvals.tsx +180 -238
- package/src/approvals/messages.ts +12 -24
- package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/app-approval.ts +60 -6
- package/src/browser/commands.ts +22 -6
- package/src/browser/composer-catalog.ts +145 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +160 -0
- package/src/browser/composer-open-messages.ts +17 -0
- package/src/browser/composer-open.ts +110 -0
- package/src/browser/discoverable-command.ts +6 -0
- package/src/browser/hints.ts +6 -0
- package/src/browser/live-connection.ts +26 -2
- package/src/browser/navigation-apps.ts +7 -0
- package/src/browser/panel-context-bridge.ts +30 -0
- package/src/browser/panel-context-receiver.ts +53 -0
- package/src/browser/panel-menu.tsx +56 -0
- package/src/browser/panel-messages.ts +35 -0
- package/src/browser/panel-shortcuts.ts +31 -0
- package/src/browser/panel-slot.ts +756 -0
- package/src/browser/panel.ts +65 -0
- package/src/browser/resource-picker.tsx +12 -1
- package/src/browser/search-commands.ts +6 -3
- package/src/capabilities/client.ts +8 -2
- package/src/capabilities/reviews.ts +205 -0
- package/src/capabilities/server.ts +10 -3
- package/src/capabilities/testing.ts +47 -0
- package/src/capabilities/types.ts +2 -2
- package/src/cli/account.ts +13 -3
- package/src/cli/admin/geoip.ts +115 -0
- package/src/cli/admin/index.ts +3 -0
- package/src/contracts/app-approval.ts +159 -12
- package/src/contracts/app.ts +14 -0
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/capabilities.ts +410 -18
- package/src/contracts/geoip.ts +42 -0
- package/src/contracts/index.ts +3 -0
- package/src/contracts/panel-context-plain.ts +99 -0
- package/src/contracts/panel-context.ts +93 -0
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/seen-hints.ts +35 -0
- package/src/contracts/shared.ts +2 -0
- package/src/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/middleware/middleware.ts +9 -0
- package/src/server/middleware/no-transform.ts +19 -0
- package/src/server/middleware/rate-limit.ts +4 -6
- package/src/server/request-context/address.ts +45 -0
- package/src/server/request-context/geoip.ts +380 -0
- package/src/server/request-context/index.ts +33 -0
- package/src/server/request-context/user-agent.ts +95 -0
- package/src/server/services/access.ts +5 -1
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/app-approval.ts +533 -90
- package/src/services/approvals/device.ts +9 -5
- package/src/services/approvals/index.ts +6 -5
- package/src/services/approvals/list.ts +26 -5
- package/src/services/auth-flows/notification-sender.ts +3 -0
- package/src/services/geoip/index.ts +164 -0
- package/src/services/geoip/store.ts +30 -0
- package/src/services/geoip/upload.ts +150 -0
- package/src/services/identity/routes.ts +5 -1
- package/src/services/index.ts +32 -3
- package/src/services/known-browsers.ts +312 -0
- package/src/services/login-address-store.ts +51 -0
- package/src/services/login-context.ts +118 -0
- package/src/services/mandates/index.ts +3 -0
- package/src/services/mandates/policy.ts +15 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/notifications/email-frame.ts +1 -1
- package/src/services/oauth-tokens.ts +16 -2
- package/src/services/panel-context-consent.ts +58 -0
- package/src/services/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +97 -16
- package/src/services/session/recent.ts +13 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +19 -17
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/services/webauthn.ts +12 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/email-html.ts +2 -0
- package/src/shared/help.ts +3 -0
- package/src/shared/redirect.ts +12 -0
- package/src/shared-text/cursor.ts +22 -0
- package/src/shared-text/documents.ts +142 -0
- package/src/shared-text/events.ts +43 -0
- package/src/shared-text/index.ts +12 -0
- package/src/shared-text/log.ts +48 -0
- package/src/shared-text/text-diff.ts +23 -0
- package/src/shared-text/types.ts +41 -0
- package/src/ssr/AdminLayout.tsx +2 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +95 -10
- package/src/ssr/Layout.tsx +66 -10
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +435 -274
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
- package/src/ssr/LayoutHelpTrigger.tsx +27 -2
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/MobileNavigation.tsx +2 -2
- package/src/ssr/PanelLayout.tsx +35 -0
- package/src/ssr/PanelRuntime.island.tsx +94 -0
- package/src/ssr/PanelSlot.tsx +158 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +69 -5
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +105 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +28 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +439 -0
- package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
- package/src/ssr/help-messages.ts +4 -0
- package/src/ssr/help.ts +20 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/index.ts +4 -3
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -0
- package/src/ssr/layout-help-start.ts +33 -7
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -0
- package/src/ssr/panel-slot.browser-harness.tsx +159 -0
- package/src/ssr/platform-messages.ts +48 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +5 -0
- package/src/ssr/shell-shortcuts.ts +13 -2
- package/src/styles/effects.css +201 -11
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +314 -63
- package/src/styles/panel.css +416 -0
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +36 -13
- package/src/styles/utilities-buttons.css +3 -2
- package/src/styles/utilities-data.css +2 -1
- package/src/styles/utilities-feedback.css +2 -2
- package/src/styles/utilities-layout.css +3 -13
- package/src/styles/utilities-markdown-editor.css +6 -6
- package/src/styles/utilities-navigation.css +22 -43
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { capabilityPreviewIssues, capabilityReviewFormIssues } from "../capabilities/reviews";
|
|
3
|
+
import {
|
|
4
|
+
CAPABILITY_REACHES,
|
|
5
|
+
CapabilityActionCheckSchema,
|
|
6
|
+
type CapabilityActionPolicy,
|
|
7
|
+
CapabilityActionReceiptSchema,
|
|
8
|
+
CapabilityActionReviewDataSchema,
|
|
9
|
+
type CapabilityQueryManifest,
|
|
10
|
+
CapabilitySemanticLinkSchema,
|
|
11
|
+
} from "../contracts/capabilities";
|
|
12
|
+
import { localeFallbackChain } from "../shared/locale";
|
|
13
|
+
import { capabilityHash, projectSchema } from "./capability-schemas";
|
|
14
|
+
|
|
15
|
+
export {
|
|
16
|
+
actionReview,
|
|
17
|
+
type CapabilityReviewForms,
|
|
18
|
+
capabilityReviewFormIssues,
|
|
19
|
+
capabilityReviewRememberScope,
|
|
20
|
+
capabilityReviewView,
|
|
21
|
+
type SlotNames,
|
|
22
|
+
} from "../capabilities/reviews";
|
|
23
|
+
|
|
24
|
+
export { CAPABILITY_MAX_REVIEW_BYTES } from "../contracts/capabilities";
|
|
25
|
+
export type CapabilityReviewContext = {
|
|
26
|
+
policy?: CapabilityActionPolicy;
|
|
27
|
+
approval?: "rememberable" | "none";
|
|
28
|
+
queries: readonly CapabilityQueryManifest[];
|
|
29
|
+
validateQueryInput: (query: CapabilityQueryManifest, input: Record<string, unknown>) => boolean;
|
|
30
|
+
locales?: readonly string[];
|
|
31
|
+
};
|
|
32
|
+
const schemaIssues = (error: z.ZodError): string[] => error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
|
|
33
|
+
let actionCheckDataHash: string | undefined;
|
|
34
|
+
/** Whether a Query answers an Action's `check`: its data schema is exactly the projected `CapabilityActionCheckSchema`. */
|
|
35
|
+
export const capabilityQueryChecksActionOutcome = (query: CapabilityQueryManifest): boolean => {
|
|
36
|
+
actionCheckDataHash ??= capabilityHash(projectSchema(CapabilityActionCheckSchema, "Action check", "output"));
|
|
37
|
+
return capabilityHash(query.dataSchema) === actionCheckDataHash;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const capabilityActionReviewIssues = (review: unknown, context: CapabilityReviewContext): string[] => {
|
|
41
|
+
const parsed = CapabilityActionReviewDataSchema.safeParse(review);
|
|
42
|
+
if (!parsed.success) return schemaIssues(parsed.error);
|
|
43
|
+
const data = parsed.data;
|
|
44
|
+
if (!("sentence" in data))
|
|
45
|
+
return context.policy
|
|
46
|
+
? ["A policy Action must return a new review"]
|
|
47
|
+
: data.approvalScope !== undefined && context.approval !== "rememberable"
|
|
48
|
+
? ["approvalScope requires rememberable approval"]
|
|
49
|
+
: [];
|
|
50
|
+
if (!context.policy) return ["An Action without policy must return a legacy review"];
|
|
51
|
+
const issues = capabilityReviewFormIssues(data);
|
|
52
|
+
if (
|
|
53
|
+
data.reach !== undefined &&
|
|
54
|
+
(!("silent" in context.policy) || CAPABILITY_REACHES.indexOf(data.reach) > CAPABILITY_REACHES.indexOf(context.policy.silent))
|
|
55
|
+
)
|
|
56
|
+
issues.push("reach exceeds the declared silent reach");
|
|
57
|
+
if (context.locales && !localeFallbackChain(data.locale, data.locale).some((locale) => context.locales?.includes(locale)))
|
|
58
|
+
issues.push("locale is not supported by the app");
|
|
59
|
+
if (data.check) {
|
|
60
|
+
const query = context.queries.find((query) => query.localId === data.check?.query);
|
|
61
|
+
if (!query || !capabilityQueryChecksActionOutcome(query)) issues.push("check.query must return CapabilityActionCheckSchema");
|
|
62
|
+
else if (!context.validateQueryInput(query, data.check.input)) issues.push("check.input does not match the Query");
|
|
63
|
+
}
|
|
64
|
+
issues.push(
|
|
65
|
+
...capabilityPreviewIssues(
|
|
66
|
+
data.preview,
|
|
67
|
+
data.links,
|
|
68
|
+
// A per-call reason never lifts the declared `sends` completeness rule.
|
|
69
|
+
data.ask === "sends" || ("ask" in context.policy && context.policy.ask === "sends"),
|
|
70
|
+
),
|
|
71
|
+
);
|
|
72
|
+
return [...new Set(issues)];
|
|
73
|
+
};
|
|
74
|
+
/** A receipt's own rules; completeness links are the result envelope's top-level `links`. */
|
|
75
|
+
export const capabilityActionReceiptIssues = (receipt: unknown, links: unknown): string[] => {
|
|
76
|
+
const parsed = CapabilityActionReceiptSchema.safeParse(receipt);
|
|
77
|
+
const resultLinks = z.array(CapabilitySemanticLinkSchema).safeParse(links);
|
|
78
|
+
return parsed.success
|
|
79
|
+
? capabilityPreviewIssues(parsed.data.preview, resultLinks.success ? resultLinks.data : undefined)
|
|
80
|
+
: schemaIssues(parsed.error);
|
|
81
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
type JsonSchema = Record<string, unknown>;
|
|
5
|
+
export const stableJsonValue = (value: unknown): unknown => {
|
|
6
|
+
if (Array.isArray(value)) return value.map(stableJsonValue);
|
|
7
|
+
if (typeof value !== "object" || value === null) return value;
|
|
8
|
+
return Object.fromEntries(
|
|
9
|
+
Object.entries(value)
|
|
10
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
11
|
+
.map(([key, entry]) => [key, stableJsonValue(entry)]),
|
|
12
|
+
);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const capabilityHash = (value: unknown): string =>
|
|
16
|
+
createHash("sha256")
|
|
17
|
+
.update(JSON.stringify(stableJsonValue(value)))
|
|
18
|
+
.digest("hex");
|
|
19
|
+
|
|
20
|
+
export const projectSchema = (schema: z.ZodType, label: string, io: "input" | "output"): JsonSchema => {
|
|
21
|
+
try {
|
|
22
|
+
const projected = z.toJSONSchema(schema, { io });
|
|
23
|
+
z.fromJSONSchema(projected);
|
|
24
|
+
return projected;
|
|
25
|
+
} catch (error) {
|
|
26
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
27
|
+
throw new Error(`${label} is not JSON-Schema-round-trippable: ${message}`);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { dates, i18n } from "@k2b/stdlib";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
CapabilityActionCallReview,
|
|
4
|
+
CapabilityActionOutcome,
|
|
5
|
+
CapabilityActionReceipt,
|
|
6
|
+
CapabilityActionSentences,
|
|
7
|
+
CapabilityAskReason,
|
|
8
|
+
CapabilityPerson,
|
|
9
|
+
CapabilityPresentationCatalog,
|
|
10
|
+
CapabilitySlot,
|
|
11
|
+
} from "../contracts/capabilities";
|
|
3
12
|
import { canonicalLocale, localeFallbackChain, normalizeLocale } from "../shared/locale";
|
|
4
13
|
import { normalizeTimeZone } from "../shared/time";
|
|
5
14
|
|
|
@@ -78,12 +87,38 @@ const words = i18n.define({
|
|
|
78
87
|
baseLocale: "en",
|
|
79
88
|
messages: {
|
|
80
89
|
en: {
|
|
90
|
+
question: ({ sentence }: { sentence: string }) => `${sentence}?`,
|
|
91
|
+
rejected: ({ action }: { action: string }) => `Not done: ${action}`,
|
|
92
|
+
unknown: ({ action }: { action: string }) => `Not known yet whether this happened: ${action}`,
|
|
93
|
+
unknownStill: ({ action }: { action: string }) => `Still not known whether this happened: ${action}`,
|
|
94
|
+
people: ({ count }: { count: number }) => `${count} ${new Intl.PluralRules("en").select(count) === "one" ? "person" : "people"}`,
|
|
95
|
+
sends: "I'm asking because this leaves Cloud.",
|
|
96
|
+
deletes: "I'm asking because you can't bring it back afterwards.",
|
|
97
|
+
cannot_undo: "I'm asking because you can't set this back.",
|
|
98
|
+
involves_people: "I'm asking because this involves or notifies other people.",
|
|
99
|
+
changes_access: "I'm asking because this changes who can see it.",
|
|
100
|
+
keeps_working: "I'm asking because this keeps acting without you.",
|
|
101
|
+
sensitive: "I'm asking because this concerns payments, invoices, addresses, or sign-in data.",
|
|
102
|
+
app_asks: ({ app }: { app: string }) => `I'm asking because ${app} always asks for this.`,
|
|
81
103
|
yes: "yes",
|
|
82
104
|
no: "no",
|
|
83
105
|
more: ({ count }: { count: number }) => `${count} more`,
|
|
84
106
|
why: "Why",
|
|
85
107
|
},
|
|
86
108
|
de: {
|
|
109
|
+
question: ({ sentence }) => `${sentence}?`,
|
|
110
|
+
rejected: ({ action }) => `Nicht ausgeführt: ${action}`,
|
|
111
|
+
unknown: ({ action }) => `Ob ${action} passiert ist, weiß ich noch nicht.`,
|
|
112
|
+
unknownStill: ({ action }) => `Ob ${action} passiert ist, weiß ich weiterhin nicht.`,
|
|
113
|
+
people: ({ count }) => `${count} ${new Intl.PluralRules("de").select(count) === "one" ? "Person" : "Personen"}`,
|
|
114
|
+
sends: "Ich frage, weil das Cloud verlässt.",
|
|
115
|
+
deletes: "Ich frage, weil du das danach nicht zurückholen kannst.",
|
|
116
|
+
cannot_undo: "Ich frage, weil du das nicht zurückstellen kannst.",
|
|
117
|
+
involves_people: "Ich frage, weil das andere Personen einbindet oder benachrichtigt.",
|
|
118
|
+
changes_access: "Ich frage, weil sich ändert, wer das sehen kann.",
|
|
119
|
+
keeps_working: "Ich frage, weil das ohne dich weiterwirkt.",
|
|
120
|
+
sensitive: "Ich frage, weil das Zahlungen, Rechnungen, Adressen oder Zugangsdaten betrifft.",
|
|
121
|
+
app_asks: ({ app }) => `Ich frage, weil ${app} hier jedes Mal fragt.`,
|
|
87
122
|
yes: "ja",
|
|
88
123
|
no: "nein",
|
|
89
124
|
more: ({ count }) => `${count} weitere`,
|
|
@@ -411,3 +446,183 @@ export const capabilitySentenceFields = (input: {
|
|
|
411
446
|
}
|
|
412
447
|
return [...fields.values()];
|
|
413
448
|
};
|
|
449
|
+
|
|
450
|
+
export type CapabilitySlotSentenceContext = { locale: string; timeZone: string };
|
|
451
|
+
export type CapabilitySentencePart =
|
|
452
|
+
| { kind: "text"; text: string }
|
|
453
|
+
| { kind: "chip"; name: string; slot: CapabilitySlot; label: string; full: string };
|
|
454
|
+
export const capabilityPersonText = (person: CapabilityPerson): string =>
|
|
455
|
+
person.name && person.address ? `${person.name} <${person.address}>` : (person.name ?? person.address ?? "");
|
|
456
|
+
|
|
457
|
+
const slotText = (slot: CapabilitySlot, context: CapabilitySlotSentenceContext, mode: "chip" | "plain" | "button" | "full"): string => {
|
|
458
|
+
const t = words.resolve([context.locale]).t;
|
|
459
|
+
switch (slot.kind) {
|
|
460
|
+
case "text":
|
|
461
|
+
return slot.value;
|
|
462
|
+
case "count": {
|
|
463
|
+
const plural = new Intl.PluralRules(context.locale).select(slot.value);
|
|
464
|
+
return `${new Intl.NumberFormat(context.locale).format(slot.value)} ${slot.noun[plural] ?? slot.noun.other}`;
|
|
465
|
+
}
|
|
466
|
+
case "person": {
|
|
467
|
+
const total = slot.total ?? slot.people.length;
|
|
468
|
+
if (mode === "button") return total === 1 ? (slot.people[0]!.name ?? slot.people[0]!.address ?? "") : t.people({ count: total });
|
|
469
|
+
const shown = mode === "full" ? slot.people : slot.people.slice(0, 3);
|
|
470
|
+
const list = shown.map((person) =>
|
|
471
|
+
mode === "chip" ? plain(person.name ?? person.address ?? "", 40, "middle") : capabilityPersonText(person),
|
|
472
|
+
);
|
|
473
|
+
const rest = total - shown.length;
|
|
474
|
+
if (rest > 0) list.push(t.more({ count: rest }));
|
|
475
|
+
return new Intl.ListFormat(context.locale, { type: "conjunction" }).format(list);
|
|
476
|
+
}
|
|
477
|
+
case "object":
|
|
478
|
+
case "place": {
|
|
479
|
+
if (mode === "button") return plain(slot.title, 24, "middle");
|
|
480
|
+
if (mode === "chip" || mode === "full") return slot.title;
|
|
481
|
+
return context.locale.toLowerCase().startsWith("de") ? `„${slot.title}“` : `“${slot.title}”`;
|
|
482
|
+
}
|
|
483
|
+
case "time": {
|
|
484
|
+
const date = (value: string) => {
|
|
485
|
+
const options = { locale: context.locale, timeZone: slot.format === "date" ? "UTC" : normalizeTimeZone(context.timeZone) };
|
|
486
|
+
return slot.format === "date" ? dates.formatDate(`${value}T12:00:00.000Z`, options) : dates.formatDateTime(value, options);
|
|
487
|
+
};
|
|
488
|
+
return slot.end ? `${date(slot.start)} – ${date(slot.end)}` : date(slot.start);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
/** Typed slots are data, never expressions. All formatting is explicit and independent of the process zone. */
|
|
494
|
+
export const capabilitySentenceParts = (
|
|
495
|
+
text: string,
|
|
496
|
+
slots: Readonly<Record<string, CapabilitySlot>>,
|
|
497
|
+
context: CapabilitySlotSentenceContext,
|
|
498
|
+
): CapabilitySentencePart[] => {
|
|
499
|
+
const parts: CapabilitySentencePart[] = [];
|
|
500
|
+
let index = 0;
|
|
501
|
+
for (const match of text.matchAll(/\{([a-z][A-Za-z0-9]{0,31})\}/g)) {
|
|
502
|
+
if (match.index > index) parts.push({ kind: "text", text: text.slice(index, match.index) });
|
|
503
|
+
const name = match[1]!;
|
|
504
|
+
const slot = Object.hasOwn(slots, name) ? slots[name] : undefined;
|
|
505
|
+
if (!slot) throw new Error(`Unknown sentence slot ${name}`);
|
|
506
|
+
const label = slotText(slot, context, "chip");
|
|
507
|
+
const full = slotText(slot, context, "full");
|
|
508
|
+
parts.push(
|
|
509
|
+
slot.kind === "text" || slot.kind === "count"
|
|
510
|
+
? { kind: "text", text: label }
|
|
511
|
+
: { kind: "chip", name, slot, label: slot.kind === "person" ? label : plain(label, 40, "middle"), full },
|
|
512
|
+
);
|
|
513
|
+
index = match.index + match[0].length;
|
|
514
|
+
}
|
|
515
|
+
if (index < text.length) parts.push({ kind: "text", text: text.slice(index) });
|
|
516
|
+
return parts;
|
|
517
|
+
};
|
|
518
|
+
const sentenceText = (
|
|
519
|
+
text: string,
|
|
520
|
+
slots: Readonly<Record<string, CapabilitySlot>>,
|
|
521
|
+
context: CapabilitySlotSentenceContext,
|
|
522
|
+
mode: "plain" | "button",
|
|
523
|
+
max = 400,
|
|
524
|
+
): string => {
|
|
525
|
+
const render = (slotMax: number) =>
|
|
526
|
+
text.replace(/\{([a-z][A-Za-z0-9]{0,31})\}/g, (_, name: string) => {
|
|
527
|
+
const slot = Object.hasOwn(slots, name) ? slots[name] : undefined;
|
|
528
|
+
if (!slot) throw new Error(`Unknown sentence slot ${name}`);
|
|
529
|
+
return plain(slotText(slot, context, mode), slotMax, "middle");
|
|
530
|
+
});
|
|
531
|
+
let result = render(Number.MAX_SAFE_INTEGER);
|
|
532
|
+
if (Array.from(result).length > max) result = render(40);
|
|
533
|
+
return plain(result, max);
|
|
534
|
+
};
|
|
535
|
+
export const capabilitySentenceText = (
|
|
536
|
+
text: string,
|
|
537
|
+
slots: Readonly<Record<string, CapabilitySlot>>,
|
|
538
|
+
context: CapabilitySlotSentenceContext,
|
|
539
|
+
): string => sentenceText(text, slots, context, "plain");
|
|
540
|
+
export type CapabilityReviewSentenceForm =
|
|
541
|
+
| "question"
|
|
542
|
+
| "unknownStill"
|
|
543
|
+
| "running"
|
|
544
|
+
| "done"
|
|
545
|
+
| "rejected"
|
|
546
|
+
| "notRun"
|
|
547
|
+
| "unknown"
|
|
548
|
+
| "short"
|
|
549
|
+
| "confirm"
|
|
550
|
+
| "decline";
|
|
551
|
+
export const capabilityReviewSentence = (
|
|
552
|
+
review: CapabilityActionCallReview,
|
|
553
|
+
form: CapabilityReviewSentenceForm,
|
|
554
|
+
context: { timeZone: string },
|
|
555
|
+
): string => {
|
|
556
|
+
const rendering = { locale: review.locale, timeZone: context.timeZone };
|
|
557
|
+
const t = words.resolve([review.locale]).t;
|
|
558
|
+
const action = () => capabilitySentenceText(review.sentence.action, review.slots, rendering);
|
|
559
|
+
if (form === "question") return plain(t.question({ sentence: action() }), 400);
|
|
560
|
+
if (form === "unknown") return plain(t.unknown({ action: action() }), 400);
|
|
561
|
+
if (form === "unknownStill") return plain(t.unknownStill({ action: action() }), 400);
|
|
562
|
+
if (form === "confirm" || form === "decline")
|
|
563
|
+
return sentenceText(review.sentence[form], review.slots, rendering, "button", Number.MAX_SAFE_INTEGER);
|
|
564
|
+
const template = form === "notRun" ? (review.sentence.notRun ?? review.sentence.rejected) : review.sentence[form];
|
|
565
|
+
if (template) return capabilitySentenceText(template, review.slots, rendering);
|
|
566
|
+
if (form === "short") return action();
|
|
567
|
+
return plain(t.rejected({ action: action() }), 400);
|
|
568
|
+
};
|
|
569
|
+
export const capabilityOutcomeSentence = (
|
|
570
|
+
review: CapabilityActionCallReview,
|
|
571
|
+
outcome: CapabilityActionOutcome,
|
|
572
|
+
receipt: CapabilityActionReceipt | undefined,
|
|
573
|
+
context: { timeZone: string },
|
|
574
|
+
): string =>
|
|
575
|
+
outcome === "done" && receipt
|
|
576
|
+
? capabilitySentenceText(receipt.text, receipt.slots ?? {}, { locale: review.locale, timeZone: context.timeZone })
|
|
577
|
+
: capabilityReviewSentence(review, outcome === "not_run" ? "notRun" : outcome, context);
|
|
578
|
+
export const capabilityAskReasonText = (reason: CapabilityAskReason, context: { locale: string; appName: string }): string => {
|
|
579
|
+
const t = words.resolve([context.locale]).t;
|
|
580
|
+
return reason === "app_asks" ? t.app_asks({ app: context.appName }) : t[reason];
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
/** Splits a Cloud frame such as "Ob {action} passiert ist, weiß ich noch nicht." around the inserted sentence. */
|
|
584
|
+
const framed = (frame: (input: { action: string }) => string, inner: CapabilitySentencePart[]): CapabilitySentencePart[] => {
|
|
585
|
+
const marker = "\u0000";
|
|
586
|
+
const [before = "", after = ""] = frame({ action: marker }).split(marker);
|
|
587
|
+
return [
|
|
588
|
+
...(before ? [{ kind: "text" as const, text: before }] : []),
|
|
589
|
+
...inner,
|
|
590
|
+
...(after ? [{ kind: "text" as const, text: after }] : []),
|
|
591
|
+
];
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* One form of a review as parts for chips: the same words as `capabilityReviewSentence`, with Cloud's frames (the
|
|
596
|
+
* question mark, "not known yet", the default rejection) around the app's sentence. Buttons and `short` stay text.
|
|
597
|
+
*/
|
|
598
|
+
export const capabilityReviewSentenceParts = (
|
|
599
|
+
review: CapabilityActionCallReview,
|
|
600
|
+
form: Exclude<CapabilityReviewSentenceForm, "short" | "confirm" | "decline">,
|
|
601
|
+
context: { timeZone: string },
|
|
602
|
+
): CapabilitySentencePart[] => {
|
|
603
|
+
const rendering = { locale: review.locale, timeZone: context.timeZone };
|
|
604
|
+
const t = words.resolve([review.locale]).t;
|
|
605
|
+
const action = () => capabilitySentenceParts(review.sentence.action, review.slots, rendering);
|
|
606
|
+
if (form === "question") return framed(({ action: sentence }) => t.question({ sentence }), action());
|
|
607
|
+
if (form === "unknown") return framed(t.unknown, action());
|
|
608
|
+
if (form === "unknownStill") return framed(t.unknownStill, action());
|
|
609
|
+
const template = form === "notRun" ? (review.sentence.notRun ?? review.sentence.rejected) : review.sentence[form];
|
|
610
|
+
if (template) return capabilitySentenceParts(template, review.slots, rendering);
|
|
611
|
+
return framed(t.rejected, action());
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
/** The parts of a receipt the app returned, in the review's language. */
|
|
615
|
+
export const capabilityReceiptParts = (
|
|
616
|
+
receipt: CapabilityActionReceipt,
|
|
617
|
+
context: { locale: string; timeZone: string },
|
|
618
|
+
): CapabilitySentencePart[] => capabilitySentenceParts(receipt.text, receipt.slots ?? {}, context);
|
|
619
|
+
|
|
620
|
+
/** Cloud's frame for an outcome it could not see, around any plain sentence. */
|
|
621
|
+
export const capabilityUnknownText = (action: string, locale: string, still = false): string => {
|
|
622
|
+
const t = words.resolve([locale]).t;
|
|
623
|
+
return plain(still ? t.unknownStill({ action }) : t.unknown({ action }), 400);
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
/** Cloud's question frame around a plain sentence, such as Cloud's own sentence for a call without a review. */
|
|
627
|
+
export const capabilityQuestionText = (sentence: string, locale: string): string =>
|
|
628
|
+
plain(words.resolve([locale]).t.question({ sentence }), 400);
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
capabilityResultSchema,
|
|
9
9
|
} from "../contracts/capabilities";
|
|
10
10
|
import type { CompiledCapabilities } from "./capabilities";
|
|
11
|
+
import { capabilityActionReceiptIssues } from "./capability-reviews";
|
|
11
12
|
|
|
12
13
|
const failure = (status: number, code: string, message: string) => Response.json({ code, message }, { status });
|
|
13
14
|
export async function invokeCapabilityStream(params: {
|
|
@@ -26,6 +27,12 @@ export async function invokeCapabilityStream(params: {
|
|
|
26
27
|
const receipt = (value: unknown) => {
|
|
27
28
|
const parsed = capabilityResultSchema(operation.definition.data).parse(value);
|
|
28
29
|
if (parsed.stream) throw new Error("Stream receipts cannot issue another stream");
|
|
30
|
+
// The same receipt rules as an ordinary result, so a stream completion cannot skip them.
|
|
31
|
+
if (parsed.receipt !== undefined) {
|
|
32
|
+
const issues =
|
|
33
|
+
params.kind === "queries" ? ["Queries cannot return a receipt"] : capabilityActionReceiptIssues(parsed.receipt, parsed.links);
|
|
34
|
+
if (issues.length > 0) throw new Error(`Stream result receipt is invalid: ${issues.join("; ")}`);
|
|
35
|
+
}
|
|
29
36
|
return parsed;
|
|
30
37
|
};
|
|
31
38
|
try {
|
|
@@ -2,6 +2,7 @@ import { type AppSkillDefinition, appSkillManifestHash, registerAppSkills, valid
|
|
|
2
2
|
import { startLiveOutbox } from "../events/live";
|
|
3
3
|
import { registerHelp } from "../services/help";
|
|
4
4
|
import { preloadLayoutHelp } from "../ssr/help";
|
|
5
|
+
import { preloadPanelConsent } from "../ssr/panel-consent";
|
|
5
6
|
import { invokeCapabilityStream } from "./capability-streams";
|
|
6
7
|
import { bindProcessApplicationId, clearProcessApplicationId } from "./process-identity";
|
|
7
8
|
/**
|
|
@@ -75,10 +76,11 @@ import { capabilityMessages } from "../shared/capability-messages";
|
|
|
75
76
|
import { normalizeLocale } from "../shared/locale";
|
|
76
77
|
import { escapeHtml } from "../shared/markdown/shared";
|
|
77
78
|
import { type CloudThemePreference, readThemePreferenceFromCookieHeader, themeBootstrapScript } from "../shared/theme";
|
|
79
|
+
import { appAccentStyle } from "../ssr/app-appearance";
|
|
78
80
|
import { appFaviconHref } from "./app-favicon";
|
|
79
81
|
import { compileAppPresentation } from "./app-presentation";
|
|
80
82
|
import { readBoundedJson } from "./bounded-json";
|
|
81
|
-
import { appRuntimeMetadata } from "./build-metadata";
|
|
83
|
+
import { appRuntimeMetadata, buildMetadata } from "./build-metadata";
|
|
82
84
|
import { compileCapabilities, invokeCompiledCapability, reviewCompiledCapability, serializeCapabilityProviderResult } from "./capabilities";
|
|
83
85
|
import { cliPluginRoutePrefixes, createCliPluginRoutes, validateAppCliModules } from "./cli-plugins";
|
|
84
86
|
import { FONT_PRELOAD_LINKS } from "./font-preloads";
|
|
@@ -89,15 +91,15 @@ import { configurePostgresApplicationName } from "./postgres-application-name";
|
|
|
89
91
|
import { getProcessSync, startProcessSync } from "./process-sync";
|
|
90
92
|
import { APP_READINESS_PATH, appReadinessResponse } from "./readiness";
|
|
91
93
|
import { appRegistry, type CapabilityRegistryRecord, capabilityRegistry } from "./registry";
|
|
92
|
-
import { canonicalRoute,
|
|
93
|
-
import { ensureRuntimeWatcher, getCurrentRuntime, stopRuntimeWatcher } from "./runtime-watcher";
|
|
94
|
-
import { servePublicAsset } from "./static-assets";
|
|
94
|
+
import { canonicalRoute, isOwnAppPath } from "./registry-validation";
|
|
95
|
+
import { ensureRuntimeWatcher, getCurrentRuntime, registeredCoreRelease, stopRuntimeWatcher } from "./runtime-watcher";
|
|
96
|
+
import { publicFileVersion, servePublicAsset, sharedStylesheetVersion, versionedPublicHref } from "./static-assets";
|
|
95
97
|
import { createStatusPreservingSsrHandler } from "./status-preserving-ssr";
|
|
96
98
|
import { WEB_VITALS_ASSET_HREF } from "./web-vitals-asset";
|
|
97
99
|
import { compileWidgetDeclarations } from "./widget-declarations";
|
|
98
100
|
|
|
99
|
-
/**
|
|
100
|
-
const
|
|
101
|
+
/** Only development links use this start stamp to pick up rebuilds. */
|
|
102
|
+
const developmentStamp = Date.now();
|
|
101
103
|
|
|
102
104
|
export type PageOptions = {
|
|
103
105
|
title?: string;
|
|
@@ -110,7 +112,7 @@ export type PageOptions = {
|
|
|
110
112
|
pwa?: { name: string };
|
|
111
113
|
};
|
|
112
114
|
|
|
113
|
-
type ResolvedPageOptions = PageOptions & { performanceRoute?: string; themePreference?: CloudThemePreference };
|
|
115
|
+
type ResolvedPageOptions = PageOptions & { performanceRoute?: string; themePreference?: CloudThemePreference; appAccent?: string };
|
|
114
116
|
|
|
115
117
|
// ── Public types ────────────────────────────────────────────────────────────
|
|
116
118
|
|
|
@@ -144,6 +146,7 @@ export type AppOptions<S extends AppSettingsMap = {}, N extends NotificationDefi
|
|
|
144
146
|
label: string;
|
|
145
147
|
links: ReadonlyArray<{ label: string; href: string; icon: string }>;
|
|
146
148
|
}>;
|
|
149
|
+
panel?: AppMeta["panel"];
|
|
147
150
|
nav?: {
|
|
148
151
|
href: string;
|
|
149
152
|
match?: string;
|
|
@@ -371,11 +374,38 @@ const validateAppPwaPart = (opts: Pick<AppOptions, "id" | "routes" | "pwa">): Ap
|
|
|
371
374
|
/** The badge route is read by the browser through the gateway, so it must be one of this app's own paths. */
|
|
372
375
|
const validateAppNavBadge = (opts: Pick<AppOptions, "id" | "routes" | "nav">): void => {
|
|
373
376
|
const badge = opts.nav?.badge;
|
|
374
|
-
if (badge !== undefined && !
|
|
377
|
+
if (badge !== undefined && !isOwnAppPath(badge, opts.routes)) {
|
|
375
378
|
throw new Error(`App "${opts.id}" declares nav.badge "${badge}"; it must be a same-origin path below one of its routes`);
|
|
376
379
|
}
|
|
377
380
|
};
|
|
378
381
|
|
|
382
|
+
const validateAppPanel = (opts: Pick<AppOptions, "id" | "routes" | "basePath" | "panel">): AppMeta["panel"] => {
|
|
383
|
+
const panel = opts.panel;
|
|
384
|
+
if (panel === undefined) return undefined;
|
|
385
|
+
if (!panel || typeof panel !== "object" || Array.isArray(panel)) throw new Error(`App "${opts.id}" declares panel; it must be an object`);
|
|
386
|
+
if (typeof panel.href !== "string" || !isOwnAppPath(panel.href, opts.routes)) {
|
|
387
|
+
throw new Error(`App "${opts.id}" declares panel.href "${panel.href}"; it must be a same-origin path below one of its routes`);
|
|
388
|
+
}
|
|
389
|
+
const pageBase = `${opts.basePath?.replace(/\/$/, "") ?? ""}/help`;
|
|
390
|
+
const path = new URL(panel.href, "https://cloud.invalid").pathname;
|
|
391
|
+
if (path === pageBase || path.startsWith(`${pageBase}/`)) {
|
|
392
|
+
throw new Error(
|
|
393
|
+
`App "${opts.id}" declares panel.href "${panel.href}"; it must not be at or below its Help path "${pageBase}", which the framework uses for Help`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
for (const field of ["label", "icon"] as const) {
|
|
397
|
+
if (typeof panel[field] !== "string" || !panel[field].trim())
|
|
398
|
+
throw new Error(`App "${opts.id}" declares panel.${field}; it must be a non-empty string`);
|
|
399
|
+
}
|
|
400
|
+
if (
|
|
401
|
+
panel.requiresRoles !== undefined &&
|
|
402
|
+
(!Array.isArray(panel.requiresRoles) || !panel.requiresRoles.every((role) => typeof role === "string"))
|
|
403
|
+
) {
|
|
404
|
+
throw new Error(`App "${opts.id}" declares panel.requiresRoles; it must be an array of strings`);
|
|
405
|
+
}
|
|
406
|
+
return { ...panel, requiresRoles: panel.requiresRoles ? [...panel.requiresRoles] : undefined };
|
|
407
|
+
};
|
|
408
|
+
|
|
379
409
|
export const defineApp = <
|
|
380
410
|
const S extends AppSettingsMap = {},
|
|
381
411
|
const N extends NotificationDefinitionMap = {},
|
|
@@ -391,6 +421,7 @@ export const defineApp = <
|
|
|
391
421
|
const cliModules = validateAppCliModules(opts.id, opts.cli);
|
|
392
422
|
const pwaPart = validateAppPwaPart(opts);
|
|
393
423
|
validateAppNavBadge(opts);
|
|
424
|
+
const panel = validateAppPanel(opts);
|
|
394
425
|
|
|
395
426
|
// ── 0. Register declared settings into the runtime registry ──────────
|
|
396
427
|
// SETTINGS_MAP is the single source of truth for validation in store.ts
|
|
@@ -412,7 +443,18 @@ export const defineApp = <
|
|
|
412
443
|
// Every island instance already mounts inside its own error boundary; this
|
|
413
444
|
// only gives the fallback Cloud's look and the reader's language.
|
|
414
445
|
errorFallback: fileURLToPath(new URL("../ssr/IslandError.tsx", import.meta.url)),
|
|
415
|
-
template: ({ body, scripts, title, description, theme, themePreference, lang, performanceRoute, pwa }) => {
|
|
446
|
+
template: ({ body, scripts, title, description, theme, themePreference, lang, performanceRoute, pwa, appAccent }) => {
|
|
447
|
+
// A URL changes only when what it names changes: the app's own files by their content, Core's shared sheets by
|
|
448
|
+
// the release of the Core build in the registry, so every app links them under one URL per Core build.
|
|
449
|
+
const sharedVersion = isDevelopment ? String(developmentStamp) : sharedStylesheetVersion(registeredCoreRelease());
|
|
450
|
+
const appStylesheet = `/public/${opts.id}/app.css`;
|
|
451
|
+
const appVersion = isDevelopment ? String(developmentStamp) : publicFileVersion(appStylesheet);
|
|
452
|
+
const faviconVersion = isDevelopment
|
|
453
|
+
? developmentStamp
|
|
454
|
+
: opts.id === "core"
|
|
455
|
+
? undefined
|
|
456
|
+
: publicFileVersion(`/public/${opts.id}/favicon.svg`);
|
|
457
|
+
const accentStyle = appAccentStyle(appAccent);
|
|
416
458
|
const fixed = theme !== undefined;
|
|
417
459
|
const resolved = theme ?? (themePreference === "light" || themePreference === "dark" ? themePreference : undefined);
|
|
418
460
|
// App pages paint the canvas before any stylesheet and give it to the
|
|
@@ -456,17 +498,17 @@ export const defineApp = <
|
|
|
456
498
|
<title>${escapeHtml(title ?? "Cloud")}</title>
|
|
457
499
|
<meta name="description" content="${escapeHtml(description ?? "Cloud workspace")}">
|
|
458
500
|
${head.app}
|
|
459
|
-
<link rel="icon" href="${appFaviconHref(opts.id,
|
|
501
|
+
<link rel="icon" href="${appFaviconHref(opts.id, faviconVersion)}">
|
|
460
502
|
<style data-cloud-css-layers>@layer properties, theme, base, components, utilities;</style>
|
|
461
503
|
<link rel="preload" href="/public/tabler-icons.woff2" as="font" type="font/woff2" crossorigin>
|
|
462
504
|
${FONT_PRELOAD_LINKS}
|
|
463
|
-
<link rel="stylesheet" href="/public/fonts.css
|
|
464
|
-
<link rel="stylesheet" href="/public/tabler-icons.css
|
|
465
|
-
<link rel="stylesheet" href="
|
|
466
|
-
<link rel="stylesheet" href="/public/global.css
|
|
505
|
+
<link rel="stylesheet" href="${versionedPublicHref("/public/fonts.css", sharedVersion)}">
|
|
506
|
+
<link rel="stylesheet" href="${versionedPublicHref("/public/tabler-icons.css", sharedVersion)}">
|
|
507
|
+
<link rel="stylesheet" href="${versionedPublicHref(appStylesheet, appVersion)}">
|
|
508
|
+
<link rel="stylesheet" href="${versionedPublicHref("/public/global.css", sharedVersion)}">${head.styles}
|
|
467
509
|
<script>${themeBootstrapScript}</script>
|
|
468
510
|
</head>
|
|
469
|
-
<body class="k2b-ui" data-k2b-app-workspace-controller="global">
|
|
511
|
+
<body class="k2b-ui${accentStyle ? " app-accent-scope" : ""}"${accentStyle ? ` style="${escapeHtml(accentStyle)}"` : ""} data-k2b-app-workspace-controller="global">
|
|
470
512
|
${body}
|
|
471
513
|
</body>
|
|
472
514
|
${scripts}
|
|
@@ -498,6 +540,7 @@ export const defineApp = <
|
|
|
498
540
|
await Promise.all([
|
|
499
541
|
preloadLayoutAnnouncements(c),
|
|
500
542
|
preloadLayoutHelp(c),
|
|
543
|
+
user ? preloadPanelConsent(c) : undefined,
|
|
501
544
|
user ? readRailSnapshot(user).then((snapshot) => c.set("railPreferences", snapshot)) : undefined,
|
|
502
545
|
]);
|
|
503
546
|
}),
|
|
@@ -528,6 +571,7 @@ export const defineApp = <
|
|
|
528
571
|
legalLinks: opts.legalLinks ? [...opts.legalLinks] : undefined,
|
|
529
572
|
searchLinks: opts.searchLinks?.map((link) => ({ ...link, keywords: link.keywords ? [...link.keywords] : undefined })),
|
|
530
573
|
pwa: pwaPart,
|
|
574
|
+
panel,
|
|
531
575
|
widgets: compileWidgetDeclarations(opts.id, opts.widgets),
|
|
532
576
|
settingKeys: opts.settings ? Object.keys(opts.settings) : undefined,
|
|
533
577
|
openapi: opts.openapi,
|
|
@@ -627,6 +671,9 @@ export const defineApp = <
|
|
|
627
671
|
pwa: meta.pwa
|
|
628
672
|
? { href: meta.pwa.href, requiresRoles: meta.pwa.requiresRoles ? [...meta.pwa.requiresRoles] : undefined }
|
|
629
673
|
: undefined,
|
|
674
|
+
panel: meta.panel
|
|
675
|
+
? { ...meta.panel, requiresRoles: meta.panel.requiresRoles ? [...meta.panel.requiresRoles] : undefined }
|
|
676
|
+
: undefined,
|
|
630
677
|
widgets: meta.widgets?.map((widget) => ({
|
|
631
678
|
...widget,
|
|
632
679
|
...(widget.settings ? { settings: structuredClone(widget.settings) } : {}),
|
|
@@ -715,7 +762,7 @@ export const defineApp = <
|
|
|
715
762
|
.get(APP_READINESS_PATH, () => appReadinessResponse(meta.id))
|
|
716
763
|
.route(ssrMountPath, routes(config))
|
|
717
764
|
.all(`${ssrMountPath}/*`, (c) => c.notFound())
|
|
718
|
-
.all("/public/*", servePublicAsset(isDevelopment))
|
|
765
|
+
.all("/public/*", servePublicAsset(isDevelopment, sharedStylesheetVersion(buildMetadata.release)))
|
|
719
766
|
.use(
|
|
720
767
|
"/_internal/sync/*",
|
|
721
768
|
requireInvocation((c) => ({
|
|
@@ -3,7 +3,8 @@ import plexPreset from "@k2b/ui/fonts/plex.css" with { type: "text" };
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Preload tags for the IBM Plex Sans faces that every page sets in its first
|
|
6
|
-
* frame: Latin text at 400, 500 (
|
|
6
|
+
* frame: Latin text at 400, 500 (list titles, medium text) and 600 (headings,
|
|
7
|
+
* buttons, labels).
|
|
7
8
|
*
|
|
8
9
|
* The preset declares its faces with `font-display: swap`. Without a preload
|
|
9
10
|
* the browser requests a face only when layout needs it, paints the fallback
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const PANEL_COOKIE = "cloud_panel";
|
|
2
|
+
export const PANEL_MIN_WIDTH_REM = 20;
|
|
3
|
+
export const PANEL_MAX_WIDTH_REM = 36;
|
|
4
|
+
|
|
5
|
+
export type PanelCookieState = { open: boolean; provider?: string; widthRem?: number };
|
|
6
|
+
const clampWidth = (value: number) => Math.min(PANEL_MAX_WIDTH_REM, Math.max(PANEL_MIN_WIDTH_REM, value));
|
|
7
|
+
|
|
8
|
+
export function readPanelCookie(cookieHeader: string | null | undefined, providers: readonly string[]): PanelCookieState {
|
|
9
|
+
const value = cookieHeader
|
|
10
|
+
?.split(";")
|
|
11
|
+
.map((part) => part.trim())
|
|
12
|
+
.find((part) => part.startsWith(`${PANEL_COOKIE}=`))
|
|
13
|
+
?.slice(PANEL_COOKIE.length + 1);
|
|
14
|
+
if (value === undefined) return { open: false };
|
|
15
|
+
const parts = value.split(":");
|
|
16
|
+
const [open, encodedProvider, width] = parts;
|
|
17
|
+
if (
|
|
18
|
+
parts.length !== 3 ||
|
|
19
|
+
(open !== "0" && open !== "1") ||
|
|
20
|
+
encodedProvider === undefined ||
|
|
21
|
+
width === undefined ||
|
|
22
|
+
(width !== "" && !/^\d{1,2}(\.\d{1,2})?$/.test(width))
|
|
23
|
+
)
|
|
24
|
+
return { open: false };
|
|
25
|
+
let provider: string;
|
|
26
|
+
try {
|
|
27
|
+
provider = decodeURIComponent(encodedProvider);
|
|
28
|
+
if (encodeURIComponent(provider) !== encodedProvider) return { open: false };
|
|
29
|
+
} catch {
|
|
30
|
+
return { open: false };
|
|
31
|
+
}
|
|
32
|
+
if ((provider && !providers.includes(provider)) || (open === "1" && !provider)) return { open: false };
|
|
33
|
+
return { open: open === "1", ...(provider ? { provider } : {}), ...(width ? { widthRem: clampWidth(Number(width)) } : {}) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function serializePanelCookie(state: PanelCookieState, secure: boolean): string {
|
|
37
|
+
const width = state.widthRem !== undefined && Number.isFinite(state.widthRem) ? Math.round(clampWidth(state.widthRem) * 100) / 100 : "";
|
|
38
|
+
return `${PANEL_COOKIE}=${state.open && state.provider ? "1" : "0"}:${encodeURIComponent(state.provider ?? "")}:${width}; Path=/; Max-Age=31536000; SameSite=Lax${secure ? "; Secure" : ""}`;
|
|
39
|
+
}
|