@k2b/cloud 0.42.0 → 0.44.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 +13 -5
- package/scripts/app-favicon.ts +7 -6
- package/scripts/build.ts +3 -0
- package/scripts/installable-assets.ts +61 -0
- package/scripts/preload.ts +3 -0
- package/src/_internal/app-access-path.ts +12 -0
- package/src/_internal/app-access-request.ts +13 -0
- package/src/_internal/app-access-resources.ts +141 -0
- package/src/_internal/app-event-catalog.ts +40 -0
- package/src/_internal/app-event-schemas.ts +127 -0
- package/src/_internal/application-package.ts +40 -0
- package/src/_internal/built-in-app-ids.json +27 -0
- package/src/_internal/capabilities.ts +185 -12
- package/src/_internal/capability-origin.ts +4 -0
- package/src/_internal/capability-streams.ts +10 -5
- package/src/_internal/define-app.ts +159 -45
- package/src/_internal/installable-assets.ts +90 -0
- package/src/_internal/installable-head.ts +22 -0
- package/src/_internal/object-store-area.ts +9 -0
- package/src/_internal/object-store-config.ts +101 -0
- package/src/_internal/object-store-key.ts +14 -0
- package/src/_internal/page-responses.ts +34 -3
- package/src/_internal/process-identity.ts +7 -2
- package/src/_internal/registry-validation.ts +15 -1
- package/src/_internal/registry.ts +44 -1
- package/src/_internal/runtime-context.ts +9 -1
- package/src/_internal/runtime-watcher.ts +8 -0
- package/src/_internal/status-preserving-ssr.ts +12 -1
- package/src/access/AppAccessEditor.browser-fixture.ts +429 -0
- package/src/access/AppAccessEditor.browser-frame.tsx +68 -0
- package/src/access/AppAccessEditor.browser-harness.tsx +65 -0
- package/src/access/AppAccessEditor.tsx +380 -0
- package/src/access/PermissionEditor.browser-harness.tsx +22 -2
- package/src/access/PermissionEditor.tsx +386 -77
- package/src/access/PrincipalAvatar.tsx +63 -0
- package/src/access/PrincipalPicker.tsx +29 -8
- package/src/access/ResourceApiKeys.tsx +3 -4
- package/src/access/app-access-adapter.ts +51 -0
- package/src/access/app-access-messages.ts +172 -0
- package/src/access/messages.ts +14 -0
- package/src/access/ui.ts +4 -1
- package/src/account/Avatar.tsx +21 -3
- package/src/account/ProfilePictureDialog.tsx +699 -0
- package/src/account/profile-picture-messages.ts +269 -0
- package/src/account/ui.ts +8 -1
- package/src/ai/admin.ts +3 -0
- package/src/ai/app-frame.ts +12 -0
- package/src/ai/application-tasks.ts +101 -0
- package/src/ai/assistant-models.ts +126 -64
- package/src/ai/capabilities.ts +19 -4
- package/src/ai/capability-execution.ts +5 -2
- package/src/ai/chart-block.ts +63 -30
- package/src/ai/chat/chart-block.tsx +12 -3
- package/src/ai/chat/composer-adapter.ts +8 -7
- package/src/ai/chat/message-actions.tsx +1 -1
- package/src/ai/chat/message-utils.ts +2 -2
- package/src/ai/chat/presentation.tsx +22 -1
- package/src/ai/chat-quotas.ts +5 -4
- package/src/ai/chat-tasks.ts +2 -2
- package/src/ai/client/controller.ts +11 -10
- package/src/ai/client/projection.ts +3 -3
- package/src/ai/code-capability-routes.ts +1 -0
- package/src/ai/compaction.ts +3 -3
- package/src/ai/credentials.ts +13 -114
- package/src/ai/default-tool-contracts.ts +4 -3
- package/src/ai/dictation-runtime.ts +4 -4
- package/src/ai/enrich.ts +2 -2
- package/src/ai/executor.ts +38 -27
- package/src/ai/http.ts +17 -29
- package/src/ai/index.ts +11 -5
- package/src/ai/inference-calls.ts +6 -4
- package/src/ai/maintenance.ts +1 -1
- package/src/ai/memory-learning-runs.ts +5 -5
- package/src/ai/memory-learning.ts +3 -3
- package/src/ai/message-queue.ts +1 -2
- package/src/ai/migrate.ts +75 -42
- package/src/ai/model-access-migrate.ts +3 -17
- package/src/ai/model-access.ts +44 -85
- package/src/ai/model-card.tsx +134 -0
- package/src/ai/model-catalog.ts +359 -0
- package/src/ai/model-chooser-messages.ts +88 -0
- package/src/ai/model-chooser.tsx +577 -0
- package/src/ai/model-delete.ts +234 -0
- package/src/ai/model-fixture.ts +39 -0
- package/src/ai/model-icon.tsx +36 -0
- package/src/ai/model-overview.ts +53 -0
- package/src/ai/model-usage.ts +45 -0
- package/src/ai/openai-api-messages.ts +0 -4
- package/src/ai/openai-api-slots.ts +10 -2
- package/src/ai/openai-api.ts +83 -16
- package/src/ai/personal-agent.ts +1 -1
- package/src/ai/prefs.ts +12 -10
- package/src/ai/projects-routes.ts +2 -1
- package/src/ai/projects.ts +13 -12
- package/src/ai/protocol.ts +2 -2
- package/src/ai/provider-connections.ts +161 -0
- package/src/ai/provider-errors.ts +138 -0
- package/src/ai/provider-fetch.ts +4 -0
- package/src/ai/provider-retry.ts +1 -1
- package/src/ai/provider.ts +2 -1
- package/src/ai/quota-limits.ts +36 -0
- package/src/ai/quota-provider.ts +11 -2
- package/src/ai/quota-report.ts +7 -4
- package/src/ai/quotas-migrate.ts +1 -0
- package/src/ai/quotas.ts +19 -8
- package/src/ai/recent-resources.ts +6 -0
- package/src/ai/request-headers.ts +14 -87
- package/src/ai/resource-refs.ts +6 -2
- package/src/ai/routes.ts +17 -18
- package/src/ai/runtime.ts +50 -22
- package/src/ai/settings.ts +93 -580
- package/src/ai/store.ts +45 -23
- package/src/ai/stream.ts +1 -1
- package/src/ai/structured-runs.ts +2 -2
- package/src/ai/structured.ts +29 -46
- package/src/ai/task-contracts.ts +25 -5
- package/src/ai/task-execution.ts +15 -6
- package/src/ai/task-model.ts +9 -0
- package/src/ai/transcription.ts +37 -21
- package/src/ai/turn-failure.ts +24 -2
- package/src/ai/types.ts +50 -62
- package/src/ai/ui.tsx +13 -0
- package/src/ai/usage.ts +8 -8
- package/src/ai/validate.ts +8 -7
- package/src/ai/view-block.ts +2 -2
- package/src/api/accounts-entities.ts +40 -16
- package/src/api/admin-ai-quotas.ts +34 -56
- package/src/api/admin-ai-usage.ts +1 -1
- package/src/api/admin-ai.ts +173 -0
- package/src/api/admin-core-settings.ts +68 -244
- package/src/api/admin-directory-shares.ts +55 -0
- package/src/api/admin-identity.ts +81 -2
- package/src/api/app-access.ts +85 -0
- package/src/api/app-approval.ts +3 -1
- package/src/api/app-event-delivery.ts +51 -0
- package/src/api/app-events.ts +123 -0
- package/src/api/app-resource-access.ts +48 -0
- package/src/api/auth/account-mismatch.ts +24 -0
- package/src/api/auth.ts +26 -3
- package/src/api/capabilities.ts +127 -15
- package/src/api/capability-streams.ts +2 -5
- package/src/api/identity-child-invocation.ts +145 -0
- package/src/api/index.ts +11 -2
- package/src/api/mcp.ts +180 -5
- package/src/api/me.ts +38 -17
- package/src/api/pwa-phone.ts +87 -48
- package/src/api/pwa.ts +34 -8
- package/src/api/resource-links.ts +83 -6
- package/src/api/resource-resolve.ts +50 -0
- package/src/api/search/schemas.ts +3 -1
- package/src/api/search.ts +9 -7
- package/src/api/widgets.ts +1 -0
- package/src/app-pwa/authority.ts +75 -0
- package/src/app-pwa/contracts.ts +177 -0
- package/src/app-pwa/core-query-error.ts +20 -0
- package/src/app-pwa/index.ts +23 -0
- package/src/app-pwa/protocol.ts +27 -0
- package/src/app-pwa/schema.ts +59 -0
- package/src/app-pwa/service.ts +861 -0
- package/src/app-pwa/transaction.ts +338 -0
- package/src/app-pwa/transport-input.ts +57 -0
- package/src/app-pwa/transport.ts +235 -0
- package/src/approvals/messages.ts +2 -2
- package/src/browser/app-access-recovery.ts +44 -0
- package/src/browser/app-session.ts +14 -3
- package/src/browser/cloud-id.ts +86 -0
- package/src/browser/live-websocket.ts +5 -0
- package/src/browser/pwa-drafts.ts +57 -0
- package/src/browser/resource-clipboard.ts +4 -0
- package/src/browser/resource-links.ts +52 -0
- package/src/browser/resource-picker.tsx +3 -2
- package/src/capabilities/calendar-sources.ts +302 -0
- package/src/capabilities/executions.ts +10 -3
- package/src/capabilities/migrate.ts +4 -0
- package/src/capabilities/nested.ts +43 -0
- package/src/capabilities/server-invoke.ts +202 -0
- package/src/capabilities/server.ts +26 -187
- package/src/capabilities/testing.ts +5 -2
- package/src/cli/access.ts +6 -5
- package/src/cli/account.ts +57 -0
- package/src/cli/admin/agents.ts +66 -0
- package/src/cli/admin/ai-models.ts +199 -0
- package/src/cli/admin/ai-quotas.ts +6 -102
- package/src/cli/admin/ai-usage.ts +4 -4
- package/src/cli/admin/app-credentials.ts +5 -3
- package/src/cli/admin/index.ts +7 -3
- package/src/cli/app-access.ts +60 -0
- package/src/cli/apps.ts +89 -26
- package/src/cli/avatar.ts +64 -0
- package/src/cli/capabilities.ts +22 -1
- package/src/cli/events.ts +320 -0
- package/src/cli/index.ts +1 -1
- package/src/config/env.ts +40 -0
- package/src/contracts/app-access.ts +52 -5
- package/src/contracts/app-approval.ts +8 -1
- package/src/contracts/app-details.ts +89 -0
- package/src/contracts/app-events.ts +51 -0
- package/src/contracts/app.ts +24 -5
- package/src/contracts/calendar-source.ts +274 -0
- package/src/contracts/calendar-view.ts +35 -0
- package/src/contracts/capabilities.ts +164 -3
- package/src/contracts/geo-admin.ts +2 -0
- package/src/contracts/geo-route.ts +0 -11
- package/src/contracts/geo.ts +14 -2
- package/src/contracts/index.ts +6 -0
- package/src/contracts/installable.ts +40 -0
- package/src/contracts/notification-types.ts +36 -9
- package/src/contracts/profile.ts +32 -0
- package/src/contracts/pwa-builds.ts +14 -0
- package/src/contracts/pwa-location.ts +38 -0
- package/src/contracts/pwa.ts +24 -3
- package/src/contracts/registry.ts +8 -0
- package/src/contracts/resource-clipboard.ts +35 -0
- package/src/contracts/resource-pages.ts +140 -0
- package/src/contracts/resource-resolution.ts +13 -0
- package/src/contracts/shared.ts +4 -0
- package/src/contracts/user-notifications.ts +2 -0
- package/src/events/app-event-delivery.ts +100 -0
- package/src/events/app-events.testing.ts +65 -0
- package/src/events/app-events.ts +134 -0
- package/src/events/core-user-created.ts +24 -0
- package/src/events/index.ts +9 -0
- package/src/events/testing.ts +2 -0
- package/src/index.ts +2 -1
- package/src/server/api/respond.ts +8 -3
- package/src/server/index.ts +1 -0
- package/src/server/middleware/app-access.ts +7 -6
- package/src/server/middleware/auth.ts +168 -66
- package/src/server/middleware/index.ts +1 -0
- package/src/server/middleware/invocation.ts +4 -2
- package/src/server/middleware/workload.ts +3 -3
- package/src/server/oauth-scope.ts +10 -0
- package/src/server/public-storage.ts +106 -0
- package/src/server/services/access.ts +8 -0
- package/src/services/accounts/app.ts +35 -3
- package/src/services/accounts/avatar.ts +342 -35
- package/src/services/accounts/base-group.ts +4 -5
- package/src/services/accounts/directory-shares.ts +157 -0
- package/src/services/accounts/entities.ts +50 -6
- package/src/services/accounts/groups.ts +3 -1
- package/src/services/accounts/identities.ts +64 -12
- package/src/services/accounts/local-groups.ts +1 -1
- package/src/services/accounts/users.ts +23 -15
- package/src/services/app-access-management.ts +519 -0
- package/src/services/app-access.ts +25 -7
- package/src/services/app-approval.ts +15 -4
- package/src/services/app-details.ts +224 -0
- package/src/services/app-discovery.ts +2 -1
- package/src/services/app-event-shapes.ts +139 -0
- package/src/services/app-events/accept.ts +116 -0
- package/src/services/app-events/admin.ts +250 -0
- package/src/services/app-events/declarations.ts +187 -0
- package/src/services/app-events/deliver.ts +218 -0
- package/src/services/app-events.ts +92 -0
- package/src/services/config-conflict.ts +11 -0
- package/src/services/core-user-events.ts +1 -0
- package/src/services/freeipa-config.ts +9 -0
- package/src/services/geo/admin.ts +4 -1
- package/src/services/geo/ledger.ts +47 -14
- package/src/services/geo/route-chain.ts +40 -18
- package/src/services/geo/route-estimate.ts +19 -0
- package/src/services/geo/route-plan.ts +0 -2
- package/src/services/geo/route-service.ts +57 -39
- package/src/services/geo/service.ts +51 -25
- package/src/services/identity/core-origin.ts +12 -0
- package/src/services/identity/index.ts +2 -1
- package/src/services/identity/invocation-actor.ts +50 -2
- package/src/services/identity/invocation-authority.ts +38 -1
- package/src/services/identity/invocation-token.ts +255 -50
- package/src/services/identity/issue-invocation.ts +9 -1
- package/src/services/identity/models-only-auth.ts +13 -4
- package/src/services/identity/nested-invocation.ts +128 -0
- package/src/services/image-metadata.ts +65 -1
- package/src/services/index.ts +24 -1
- package/src/services/ipa/sync.ts +6 -2
- package/src/services/ipa/users.ts +4 -1
- package/src/services/mandates/policy.ts +19 -12
- package/src/services/notifications/browser.ts +4 -0
- package/src/services/notifications/catalog.ts +5 -2
- package/src/services/notifications/platform.ts +7 -2
- package/src/services/notifications/user.ts +18 -7
- package/src/services/oauth-tokens.ts +28 -2
- package/src/services/object-store.ts +217 -0
- package/src/services/providers/local/users.ts +5 -0
- package/src/services/pwa-devices.ts +147 -37
- package/src/services/service-account-credentials.ts +5 -2
- package/src/services/session/index.ts +14 -0
- package/src/services/settings/core-settings.ts +91 -47
- package/src/services/settings/index.ts +8 -0
- package/src/services/settings/versions.ts +26 -0
- package/src/shared/ai-models.ts +274 -0
- package/src/shared/ai-quotas.ts +2 -2
- package/src/shared/ai-usage.ts +2 -2
- package/src/shared/index.ts +2 -0
- package/src/shared-text/documents.ts +25 -8
- package/src/shared-text/log.ts +1 -1
- package/src/shared-text/text-diff.ts +75 -0
- package/src/ssr/AdminArea.tsx +131 -0
- package/src/ssr/AdminLayout.tsx +11 -3
- package/src/ssr/AdminSidebar.tsx +7 -7
- package/src/ssr/AppAccessDenied.tsx +114 -0
- package/src/ssr/AppBadge.tsx +14 -0
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/AppPwaRuntime.island.tsx +82 -0
- package/src/ssr/CloudShell.island.tsx +25 -4
- package/src/ssr/Layout.tsx +15 -7
- package/src/ssr/PwaLayout.tsx +73 -8
- package/src/ssr/PwaPartCount.island.tsx +23 -0
- package/src/ssr/PwaRuntime.island.tsx +85 -6
- package/src/ssr/SettingsSaveOutcome.tsx +58 -0
- package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
- package/src/ssr/ShellRuntime.island.tsx +5 -0
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +1 -1
- package/src/ssr/admin-active-link.ts +16 -0
- package/src/ssr/admin-navigation.ts +26 -20
- package/src/ssr/app-badges.ts +31 -7
- package/src/ssr/app-pwa-document.tsx +16 -0
- package/src/ssr/app-pwa-messages.ts +17 -0
- package/src/ssr/index.ts +8 -0
- package/src/ssr/layout-context.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +3 -1
- package/src/ssr/platform-messages.ts +9 -1
- package/src/ssr/pwa-messages.ts +10 -2
- package/src/ssr/rail-navigation.ts +5 -1
- package/src/ssr/settings-draft.ts +153 -0
- package/src/ssr/shell-data.tsx +2 -0
- package/src/styles/global.css +1 -0
- package/src/styles/profile-picture.css +270 -0
- package/src/styles/utilities-navigation.css +6 -0
- package/src/workflows/ai/policy.ts +49 -0
- package/src/workflows/ai/runtime.ts +131 -21
- package/src/workflows/ai/store.ts +283 -35
- package/src/workflows/ai/types.ts +8 -2
- package/src/workflows/ai-actions.ts +148 -120
- package/src/workflows/authoring.ts +31 -0
- package/src/workflows/contracts.ts +1 -0
- package/src/workflows/definition.ts +2 -0
- package/src/workflows/editor.ts +15 -0
- package/src/workflows/index.ts +2 -1
- package/src/workflows/language/compiler.ts +34 -3
- package/src/workflows/runtime/executor.ts +132 -33
- package/src/workflows/runtime/index.ts +2 -0
- package/src/workflows/runtime/ports.ts +5 -1
- package/src/workflows/store/actions.ts +1 -1
- package/src/workflows/store/definitions.ts +5 -0
- package/src/workflows/store/runs.ts +5 -3
- package/src/workflows/store/worker.ts +7 -2
- package/src/ai/model-pricing.ts +0 -43
- package/src/ai/model-request-settings.ts +0 -98
- package/src/ai/settings-messages.ts +0 -129
- package/src/shared/ai-model-request-settings.ts +0 -21
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { PWA_AUTH_PATH, PWA_SCOPE } from "./pwa-paths";
|
|
2
|
+
|
|
3
|
+
const appTarget = (to: string | undefined): URL | undefined => {
|
|
4
|
+
if (!to || to.length > 2048 || !to.startsWith(PWA_SCOPE) || to.includes("//") || to.includes("\\")) return undefined;
|
|
5
|
+
const url = new URL(to, "http://launch.invalid");
|
|
6
|
+
// Judge the path the browser will request: dot segments and escapes are resolved by then.
|
|
7
|
+
let path: string;
|
|
8
|
+
try {
|
|
9
|
+
path = decodeURIComponent(url.pathname);
|
|
10
|
+
} catch {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
const inside = (value: string) => value.startsWith(PWA_SCOPE) && value !== PWA_AUTH_PATH && !value.startsWith(`${PWA_AUTH_PATH}/`);
|
|
14
|
+
if (url.origin !== "http://launch.invalid" || !inside(url.pathname) || !inside(path) || path.includes("//")) return undefined;
|
|
15
|
+
return url;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A path inside the app to return to after the launch bounce; anything else becomes `/pwa/`.
|
|
20
|
+
* The `pwa_launch` marker lets the page stop instead of bouncing again.
|
|
21
|
+
*/
|
|
22
|
+
export const launchTarget = (to: string | undefined): string => {
|
|
23
|
+
const url = appTarget(to);
|
|
24
|
+
if (!url) return `${PWA_SCOPE}?pwa_launch=1`;
|
|
25
|
+
url.searchParams.set("pwa_launch", "1");
|
|
26
|
+
return url.pathname + url.search;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** A remembered page, without launch markers or welcome/error states. */
|
|
30
|
+
export const rememberedPwaAddress = (to: string | undefined): string | undefined => {
|
|
31
|
+
if (to?.includes("#")) return undefined;
|
|
32
|
+
const url = appTarget(to);
|
|
33
|
+
if (!url || (url.pathname === PWA_SCOPE && url.searchParams.has("pwa")) || decodeURIComponent(url.pathname) === `${PWA_SCOPE}offline`)
|
|
34
|
+
return undefined;
|
|
35
|
+
url.searchParams.delete("pwa_launch");
|
|
36
|
+
if (url.pathname === PWA_SCOPE) url.searchParams.delete("launch");
|
|
37
|
+
return url.pathname + url.search;
|
|
38
|
+
};
|
package/src/contracts/pwa.ts
CHANGED
|
@@ -17,11 +17,14 @@ export const PWA_MANIFEST_PATH = "/pwa/manifest.webmanifest";
|
|
|
17
17
|
*/
|
|
18
18
|
export const PWA_CANVAS_COLORS = { light: "#fafafa", dark: "#090d12" } as const;
|
|
19
19
|
|
|
20
|
-
/** Cookie names.
|
|
20
|
+
/** Cookie names. The session and last address reach parts; device and pairing secrets use Core-only paths. */
|
|
21
21
|
export const PWA_COOKIES = {
|
|
22
22
|
session: "pwa_session",
|
|
23
|
+
lastAddress: "pwa_last_address",
|
|
23
24
|
device: "pwa_device",
|
|
24
25
|
pairing: "pwa_pairing",
|
|
26
|
+
/** Set by the launch when the device's pairing has ended; the next page deletes the app's drafts and the marker. */
|
|
27
|
+
ended: "pwa_ended",
|
|
25
28
|
} as const;
|
|
26
29
|
|
|
27
30
|
export const PWA_LIMITS = {
|
|
@@ -87,7 +90,7 @@ const Code = z.string().regex(/^\d{6}$/);
|
|
|
87
90
|
|
|
88
91
|
export const PwaPlatformSchema = z.enum(["ios", "android", "other"]);
|
|
89
92
|
export type PwaPlatform = z.infer<typeof PwaPlatformSchema>;
|
|
90
|
-
export const PwaPairingStateSchema = z.enum(["pending", "claimed", "confirmed", "completed", "cancelled"]);
|
|
93
|
+
export const PwaPairingStateSchema = z.enum(["pending", "claimed", "awaiting_device", "confirmed", "completed", "rejected", "cancelled"]);
|
|
91
94
|
export type PwaPairingState = z.infer<typeof PwaPairingStateSchema>;
|
|
92
95
|
/** States of `/pwa/` the launch bounce leads to when it cannot renew. */
|
|
93
96
|
export const PwaLaunchStateSchema = z.enum(["new", "ended", "blocked", "unavailable"]);
|
|
@@ -143,9 +146,27 @@ export const PwaDeviceListSchema = z.object({ items: z.array(PwaDeviceViewSchema
|
|
|
143
146
|
export const PwaClaimSchema = z.object({ secret: z.string().regex(SECRET_PATTERN), platform: PwaPlatformSchema }).strict();
|
|
144
147
|
const Account = z.object({ name: z.string() }).strict();
|
|
145
148
|
export const PwaClaimResultSchema = z.object({ code: Code, account: Account, expiresAt: DateTime }).strict();
|
|
149
|
+
export const PwaDeviceDecisionSchema = z
|
|
150
|
+
.object({
|
|
151
|
+
confirmationToken: z.string().regex(SECRET_PATTERN),
|
|
152
|
+
decision: z.enum(["confirm", "reject"]),
|
|
153
|
+
})
|
|
154
|
+
.strict();
|
|
146
155
|
export const PwaCompleteResultSchema = z.discriminatedUnion("state", [
|
|
147
156
|
z.object({ state: z.literal("paired") }).strict(),
|
|
157
|
+
z.object({ state: z.literal("rejected") }).strict(),
|
|
158
|
+
z
|
|
159
|
+
.object({
|
|
160
|
+
state: z.literal("confirmation_required"),
|
|
161
|
+
account: Account,
|
|
162
|
+
administrator: Account,
|
|
163
|
+
confirmationToken: z.string().regex(SECRET_PATTERN),
|
|
164
|
+
expiresAt: DateTime,
|
|
165
|
+
})
|
|
166
|
+
.strict(),
|
|
148
167
|
z.object({ state: z.literal("waiting"), code: Code, account: Account, expiresAt: DateTime }).strict(),
|
|
149
168
|
]);
|
|
150
|
-
export const PwaRenewResultSchema = z
|
|
169
|
+
export const PwaRenewResultSchema = z
|
|
170
|
+
.object({ renewed: z.boolean(), otherAccount: Account.optional(), builds: z.record(z.string(), z.string()) })
|
|
171
|
+
.strict();
|
|
151
172
|
export const PwaRenameSchema = z.object({ name: z.string().trim().min(1).max(PWA_LIMITS.nameMaxLength) }).strict();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AppAdminNavigationGroup, AppAppearance, AppAppearanceColor, AppPanel, AppPresentationCatalog, AppSearchLink } from "./app";
|
|
2
2
|
import type { AppAccessRule } from "./app-access";
|
|
3
3
|
import type { CapabilityManifest, CapabilityPresentationCatalog } from "./capabilities";
|
|
4
|
+
import type { AppInstallable } from "./installable";
|
|
4
5
|
import type { PlatformPermission } from "./outgoing-mail";
|
|
5
6
|
import type { Role } from "./shared";
|
|
6
7
|
import type { DashboardWidgetPresentation, DashboardWidgetSize, WidgetSettingsDeclaration } from "./widgets";
|
|
@@ -24,6 +25,10 @@ export type AppRegistryNav = {
|
|
|
24
25
|
export type AppRegistryPwaPart = {
|
|
25
26
|
href: string;
|
|
26
27
|
requiresRoles?: Role[];
|
|
28
|
+
/** Count route for the part's sidebar entry; older readers ignore it. */
|
|
29
|
+
badge?: string;
|
|
30
|
+
/** Summary route for the part's line on Start; older readers ignore it. */
|
|
31
|
+
summary?: string;
|
|
27
32
|
};
|
|
28
33
|
|
|
29
34
|
export type AppRegistryCapabilitySummary = {
|
|
@@ -81,6 +86,8 @@ export type AppRegistryEntry = {
|
|
|
81
86
|
access?: AppAccessRule;
|
|
82
87
|
platformPermissions?: readonly PlatformPermission[];
|
|
83
88
|
id: string;
|
|
89
|
+
/** Shared object-store package; omitted means the app ID. */
|
|
90
|
+
package?: string;
|
|
84
91
|
name: string;
|
|
85
92
|
icon: string;
|
|
86
93
|
description: string;
|
|
@@ -105,6 +112,7 @@ export type AppRegistryEntry = {
|
|
|
105
112
|
searchLinks?: readonly AppSearchLink[];
|
|
106
113
|
/** The app's part of the mobile app (preview); `href` is `/pwa/<id>`. Older readers ignore it. */
|
|
107
114
|
pwa?: AppRegistryPwaPart;
|
|
115
|
+
installable?: AppInstallable;
|
|
108
116
|
/** Optional panel declaration; older readers ignore it. */
|
|
109
117
|
panel?: AppPanel;
|
|
110
118
|
widgets?: AppRegistryWidget[];
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { type CloudResourceRef, CloudResourceRefSchema } from "./capabilities";
|
|
3
|
+
import { cloudResourceHref } from "./resource-href";
|
|
3
4
|
|
|
4
5
|
export const CLOUD_RESOURCE_CLIPBOARD_MIME_TYPE = "application/vnd.k2b.cloud-resource-ref+json";
|
|
5
6
|
export const CLOUD_RESOURCE_CLIPBOARD_WEB_FORMAT = `web ${CLOUD_RESOURCE_CLIPBOARD_MIME_TYPE}`;
|
|
@@ -31,3 +32,37 @@ export const parseCloudResourceClipboard = (value: string, cloudUrl: string): Cl
|
|
|
31
32
|
return null;
|
|
32
33
|
}
|
|
33
34
|
};
|
|
35
|
+
|
|
36
|
+
/** Longest permalink a consumer accepts, like `CloudResourceHrefSchema`. */
|
|
37
|
+
const CLOUD_RESOURCE_URL_MAX_LENGTH = 8_192;
|
|
38
|
+
const CLOUD_RESOURCE_PATH = /^\/open\/([^/]+)\/~([^/]+)$/;
|
|
39
|
+
|
|
40
|
+
/** The absolute permalink of a resource: the text form people copy, paste, and open. */
|
|
41
|
+
export const formatCloudResourceUrl = (ref: CloudResourceRef, cloudUrl: string): string =>
|
|
42
|
+
new URL(cloudResourceHref(CloudResourceRefSchema.parse(ref)), cloudUrl).href;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reads a resource from its permalink on this Cloud, absolute or root-relative. Anything else, such as another
|
|
46
|
+
* installation's link, an app page, or a query or fragment, returns `null`.
|
|
47
|
+
*/
|
|
48
|
+
export const parseCloudResourceUrl = (value: string, cloudUrl: string): CloudResourceRef | null => {
|
|
49
|
+
const text = value.trim();
|
|
50
|
+
if (text.length === 0 || text.length > CLOUD_RESOURCE_URL_MAX_LENGTH) return null;
|
|
51
|
+
let url: URL;
|
|
52
|
+
try {
|
|
53
|
+
url = text.startsWith("/") && !text.startsWith("//") ? new URL(text, cloudUrl) : new URL(text);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (url.origin !== cloudUrl || url.search || url.hash) return null;
|
|
58
|
+
const match = CLOUD_RESOURCE_PATH.exec(url.pathname);
|
|
59
|
+
if (!match?.[1] || !match[2]) return null;
|
|
60
|
+
let id: string;
|
|
61
|
+
try {
|
|
62
|
+
id = decodeURIComponent(match[2]);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const ref = CloudResourceRefSchema.safeParse({ type: match[1], id });
|
|
67
|
+
return ref.success ? ref.data : null;
|
|
68
|
+
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { CloudResourceRef } from "./capabilities";
|
|
2
|
+
|
|
3
|
+
/** Eight alternate pages per Type, within the manifest's 200-Type budget. */
|
|
4
|
+
export const RESOURCE_PAGES_PER_TYPE = 8;
|
|
5
|
+
/** Like other manifest route declarations, page patterns are bounded short paths. */
|
|
6
|
+
export const RESOURCE_PAGE_MAX_LENGTH = 300;
|
|
7
|
+
export const RESOURCE_URL_MAX_LENGTH = 8_192;
|
|
8
|
+
|
|
9
|
+
type Part = { literal: string } | { capture: string };
|
|
10
|
+
type Pattern = { segments: Part[]; query: [string, Part][] };
|
|
11
|
+
const capture = /^:([a-zA-Z][a-zA-Z0-9_]*)$/;
|
|
12
|
+
const decode = (value: string): string => decodeURIComponent(value);
|
|
13
|
+
const part = (value: string): Part => {
|
|
14
|
+
const match = capture.exec(value);
|
|
15
|
+
if (match?.[1]) return { capture: match[1] };
|
|
16
|
+
if (!value || value.includes(":")) throw new Error("Captures must occupy a whole segment or query value");
|
|
17
|
+
const literal = decode(value);
|
|
18
|
+
if (!literal || literal === "." || literal === ".." || /[\\/\u0000-\u001f]/.test(literal)) throw new Error("Invalid literal segment");
|
|
19
|
+
return { literal };
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const parsePattern = (path: string): Pattern => {
|
|
23
|
+
if (!path.startsWith("/") || path.startsWith("//") || path.length > RESOURCE_PAGE_MAX_LENGTH || /[\s#\\]/.test(path))
|
|
24
|
+
throw new Error(`Expected a root-relative page pattern of at most ${RESOURCE_PAGE_MAX_LENGTH} characters`);
|
|
25
|
+
const [pathname = "", search = "", extra] = path.split("?");
|
|
26
|
+
if (extra !== undefined) throw new Error("Page patterns may contain only one query separator");
|
|
27
|
+
const route = pathname.replace(/\/$/, "").slice(1);
|
|
28
|
+
const segments = route ? route.split("/").map(part) : [];
|
|
29
|
+
const keys = new Set<string>();
|
|
30
|
+
const query: [string, Part][] = search
|
|
31
|
+
? search.split("&").map((entry) => {
|
|
32
|
+
const [key = "", value, extraValue] = entry.split("=");
|
|
33
|
+
if (!key || value === undefined || extraValue !== undefined) throw new Error("Expected query key=value");
|
|
34
|
+
const decodedKey = decode(key.replace(/\+/g, " "));
|
|
35
|
+
if (!decodedKey || decodedKey.includes(":") || keys.has(decodedKey)) throw new Error("Invalid or repeated query key");
|
|
36
|
+
keys.add(decodedKey);
|
|
37
|
+
return [decodedKey, part(value.replace(/\+/g, " "))];
|
|
38
|
+
})
|
|
39
|
+
: [];
|
|
40
|
+
const parts = [...segments, ...query.map(([, value]) => value)];
|
|
41
|
+
if (parts.filter((value) => "capture" in value && value.capture === "id").length !== 1)
|
|
42
|
+
throw new Error("A page pattern must contain exactly one :id capture");
|
|
43
|
+
return { segments, query };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Validates syntax and ownership; prefixes use the gateway's literal segment boundary. */
|
|
47
|
+
export const resourcePageIssue = (path: string, prefixes?: readonly string[], appId?: string): string | null => {
|
|
48
|
+
try {
|
|
49
|
+
const pattern = parsePattern(path);
|
|
50
|
+
const namespace = pattern.segments[0];
|
|
51
|
+
const owner = pattern.segments[1];
|
|
52
|
+
if (
|
|
53
|
+
appId &&
|
|
54
|
+
namespace &&
|
|
55
|
+
"literal" in namespace &&
|
|
56
|
+
namespace.literal === "app" &&
|
|
57
|
+
(!owner || !("literal" in owner) || owner.literal !== appId)
|
|
58
|
+
)
|
|
59
|
+
return "Only app <id> may declare pages below /app/<id>";
|
|
60
|
+
if (
|
|
61
|
+
prefixes &&
|
|
62
|
+
!prefixes.some((prefix) => {
|
|
63
|
+
if (prefix === "/") return true;
|
|
64
|
+
if (!prefix.startsWith("/") || /[?#%\\:]/.test(prefix)) return false;
|
|
65
|
+
const segments = prefix.replace(/\/$/, "").slice(1).split("/");
|
|
66
|
+
return segments.every((value, index) => {
|
|
67
|
+
const segment = pattern.segments[index];
|
|
68
|
+
return segment && "literal" in segment && segment.literal === value;
|
|
69
|
+
});
|
|
70
|
+
})
|
|
71
|
+
)
|
|
72
|
+
return "Page pattern must lie under an application's declared route prefix";
|
|
73
|
+
return null;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return error instanceof Error ? error.message : "Invalid page pattern";
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type CloudResourcePage = { type: string; path: string; prefixes: readonly string[] };
|
|
80
|
+
|
|
81
|
+
/** Required query keys outrank literal path segments. Equal best scores for different refs are ambiguous. */
|
|
82
|
+
export const matchCloudResourcePage = (path: string, pages: readonly CloudResourcePage[]): CloudResourceRef | null => {
|
|
83
|
+
if (!path.startsWith("/") || path.startsWith("//") || path.length > RESOURCE_URL_MAX_LENGTH || /[\s\\]/.test(path)) return null;
|
|
84
|
+
try {
|
|
85
|
+
const url = new URL(path, "https://cloud.invalid");
|
|
86
|
+
const route = url.pathname.replace(/\/$/, "").slice(1);
|
|
87
|
+
const segments = route ? route.split("/").map(decode) : [];
|
|
88
|
+
// URLSearchParams silently repairs malformed escapes: validate before reading it.
|
|
89
|
+
decode(url.search.replace(/\+/g, " "));
|
|
90
|
+
if (segments.some((value) => !value)) return null;
|
|
91
|
+
let best: { ref: CloudResourceRef; queries: number; literals: number } | undefined;
|
|
92
|
+
let ambiguous = false;
|
|
93
|
+
for (const page of pages) {
|
|
94
|
+
if (resourcePageIssue(page.path, page.prefixes, page.type.split(".")[0])) continue;
|
|
95
|
+
const pattern = parsePattern(page.path);
|
|
96
|
+
if (segments.length !== pattern.segments.length) continue;
|
|
97
|
+
let id: string | undefined;
|
|
98
|
+
const matches = (part: Part, value: string | undefined): boolean => {
|
|
99
|
+
if (!value) return false;
|
|
100
|
+
if ("literal" in part) return part.literal === value;
|
|
101
|
+
if (part.capture === "id") id = value;
|
|
102
|
+
return true;
|
|
103
|
+
};
|
|
104
|
+
if (!pattern.segments.every((part, index) => matches(part, segments[index]))) continue;
|
|
105
|
+
if (
|
|
106
|
+
!pattern.query.every(([key, part]) => {
|
|
107
|
+
const values = url.searchParams.getAll(key);
|
|
108
|
+
return values.length === 1 && matches(part, values[0]);
|
|
109
|
+
}) ||
|
|
110
|
+
!id
|
|
111
|
+
)
|
|
112
|
+
continue;
|
|
113
|
+
const ref = { type: page.type, id };
|
|
114
|
+
const queries = pattern.query.length;
|
|
115
|
+
const literals = pattern.segments.filter((part) => "literal" in part).length;
|
|
116
|
+
if (!best || queries > best.queries || (queries === best.queries && literals > best.literals)) {
|
|
117
|
+
best = { ref, queries, literals };
|
|
118
|
+
ambiguous = false;
|
|
119
|
+
} else if (queries === best.queries && literals === best.literals && (ref.type !== best.ref.type || ref.id !== best.ref.id)) {
|
|
120
|
+
ambiguous = true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return best && !ambiguous ? best.ref : null;
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** No network activity: accept one local URL token and reject origin tricks before catalog lookup. */
|
|
130
|
+
export const localCloudResourceUrl = (text: string, origin: string | null): URL | null => {
|
|
131
|
+
if (!text || text.length > RESOURCE_URL_MAX_LENGTH || /[\s\\\u0000-\u001f]/.test(text) || text.startsWith("//")) return null;
|
|
132
|
+
try {
|
|
133
|
+
const relative = text.startsWith("/");
|
|
134
|
+
const url = relative ? new URL(text, origin ?? "https://cloud.invalid") : new URL(text);
|
|
135
|
+
if (!/^https?:$/.test(url.protocol) || url.username || url.password || (!relative && (!origin || url.origin !== origin))) return null;
|
|
136
|
+
return url;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { CloudResourceHrefSchema, CloudResourceRefSchema } from "./capabilities";
|
|
3
|
+
|
|
4
|
+
export const ResolvedCloudResourceUrlSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
ref: CloudResourceRefSchema,
|
|
7
|
+
title: z.string().min(1).max(500),
|
|
8
|
+
icon: z.string().min(1).max(120).optional(),
|
|
9
|
+
href: CloudResourceHrefSchema,
|
|
10
|
+
})
|
|
11
|
+
.strict();
|
|
12
|
+
|
|
13
|
+
export type ResolvedCloudResourceUrl = z.infer<typeof ResolvedCloudResourceUrlSchema>;
|
package/src/contracts/shared.ts
CHANGED
|
@@ -392,6 +392,8 @@ export type SettingOption = {
|
|
|
392
392
|
export type SettingValueSource = "custom" | "env" | "default";
|
|
393
393
|
|
|
394
394
|
export type SettingEntry = {
|
|
395
|
+
updatedAt?: string | null;
|
|
396
|
+
updatedBy?: { id: string; name: string } | null;
|
|
395
397
|
key: string;
|
|
396
398
|
label: string;
|
|
397
399
|
kind: SettingKind;
|
|
@@ -443,6 +445,8 @@ export type ServiceAccountRequestActor = {
|
|
|
443
445
|
delegatedUser: User | null;
|
|
444
446
|
scopes: string[];
|
|
445
447
|
credentialId?: string | null;
|
|
448
|
+
/** Public OAuth client identifier, never the bearer token. */
|
|
449
|
+
oauthClientId?: string | null;
|
|
446
450
|
credentialExpiresAt?: string | null;
|
|
447
451
|
delegation?: InvocationProvenance;
|
|
448
452
|
};
|
|
@@ -12,6 +12,8 @@ export const UserNotificationPreferenceSchema = z.object({
|
|
|
12
12
|
description: z.string(),
|
|
13
13
|
recommendedChannels: z.array(z.string()),
|
|
14
14
|
requiredChannels: z.array(z.string()),
|
|
15
|
+
excludedChannels: z.array(z.string()),
|
|
16
|
+
excludedReason: z.string().nullable(),
|
|
15
17
|
selectedChannels: z.array(z.string()),
|
|
16
18
|
effectiveChannels: z.array(z.string()),
|
|
17
19
|
customized: z.boolean(),
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { Topic, TopicConfig } from "@k2b/sync";
|
|
2
|
+
import { sql } from "bun";
|
|
3
|
+
import { assertAppEventSize } from "../_internal/app-event-schemas";
|
|
4
|
+
import { lazySync } from "../_internal/process-sync";
|
|
5
|
+
import { type AppEventEnvelope, AppEventEnvelopeSchema } from "../contracts/app-events";
|
|
6
|
+
import { logger } from "../services/logging";
|
|
7
|
+
import { createPgOutbox } from "../services/outbox";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Frozen shared stream. It only buffers events until Core has stored them, and
|
|
11
|
+
* JetStream reserves `maxBytes` on every replica: 256 MiB hold 52,428 envelopes
|
|
12
|
+
* at the 4096-byte limit plus a 1024-byte transport allowance. The shared
|
|
13
|
+
* producer budget and thin-envelope outage scenarios are in operations/app-events.
|
|
14
|
+
* Retention passing unseen events becomes a visible gap.
|
|
15
|
+
*/
|
|
16
|
+
export const appEventTopicConfig = () =>
|
|
17
|
+
({
|
|
18
|
+
id: "cloud:app-events",
|
|
19
|
+
owner: "cloud",
|
|
20
|
+
retention: { maxAgeMs: 7 * 24 * 3_600_000, maxBytes: 256 * 1024 ** 2 },
|
|
21
|
+
maxPayloadBytes: 8 * 1024,
|
|
22
|
+
deadLetterRetention: { maxBytes: 1024 ** 2 },
|
|
23
|
+
}) satisfies TopicConfig;
|
|
24
|
+
|
|
25
|
+
const topic = lazySync((sync) => sync.topic<AppEventEnvelope>(appEventTopicConfig()));
|
|
26
|
+
export const appEventTopic = (): Topic<AppEventEnvelope> => topic();
|
|
27
|
+
|
|
28
|
+
export type AppEventOutboxRow = {
|
|
29
|
+
id: string;
|
|
30
|
+
app_id: string;
|
|
31
|
+
created_at: Date | string;
|
|
32
|
+
attempts: number;
|
|
33
|
+
ordering_key: string;
|
|
34
|
+
payload: AppEventEnvelope;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The committed row owns identity, source and timestamp, including for SQL writers. */
|
|
38
|
+
export const appEventFromOutbox = (row: AppEventOutboxRow): AppEventEnvelope => {
|
|
39
|
+
const envelope = AppEventEnvelopeSchema.parse({
|
|
40
|
+
...row.payload,
|
|
41
|
+
id: row.id,
|
|
42
|
+
source: row.app_id,
|
|
43
|
+
time: new Date(row.created_at).toISOString(),
|
|
44
|
+
});
|
|
45
|
+
assertAppEventSize(envelope);
|
|
46
|
+
return envelope;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const appEventOutbox = (appId: string, publish: (row: AppEventOutboxRow) => Promise<unknown>) =>
|
|
50
|
+
createPgOutbox<AppEventOutboxRow>({
|
|
51
|
+
table: "events.outbox",
|
|
52
|
+
name: `events:app:${appId}`,
|
|
53
|
+
where: { kind: "event", app_id: appId },
|
|
54
|
+
orderBy: "ordering_key",
|
|
55
|
+
sequence: "seq",
|
|
56
|
+
reconcileIntervalMs: 1_000,
|
|
57
|
+
publish,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const dispatchers = new Map<string, (() => void) | null>();
|
|
61
|
+
export const registerAppEventPublisher = (appId: string): void => {
|
|
62
|
+
if (!dispatchers.has(appId)) dispatchers.set(appId, null);
|
|
63
|
+
};
|
|
64
|
+
export const wakeAppEventDelivery = (appId: string): void => dispatchers.get(appId)?.();
|
|
65
|
+
|
|
66
|
+
export const startAppEventDelivery = async (appId: string): Promise<(() => Promise<void>) | null> => {
|
|
67
|
+
if (!dispatchers.has(appId)) return null;
|
|
68
|
+
const [installed] = await sql<{ ready: boolean }[]>`
|
|
69
|
+
SELECT to_regprocedure('events.enqueue(uuid,text,text,text,jsonb,text)') IS NOT NULL AS ready
|
|
70
|
+
`;
|
|
71
|
+
if (!installed?.ready) throw new Error(`"${appId}" publishes app events: update Cloud Core first to install events.outbox`);
|
|
72
|
+
const destination = appEventTopic();
|
|
73
|
+
const outbox = appEventOutbox(appId, (row) =>
|
|
74
|
+
destination.publish({
|
|
75
|
+
data: appEventFromOutbox(row),
|
|
76
|
+
orderingKey: row.ordering_key,
|
|
77
|
+
idempotencyKey: row.id,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
outbox.start();
|
|
81
|
+
dispatchers.set(appId, () => void outbox.notify());
|
|
82
|
+
const log = logger("events:app");
|
|
83
|
+
const staleCheck = setInterval(() => {
|
|
84
|
+
sql<
|
|
85
|
+
{ count: number }[]
|
|
86
|
+
>`SELECT COUNT(*)::int AS count FROM events.outbox WHERE kind = 'event' AND app_id = ${appId} AND created_at < now() - interval '60 seconds'`
|
|
87
|
+
.then(([row]) => {
|
|
88
|
+
if (row && row.count > 0) log.warn("App events wait to be published", { appId, count: row.count });
|
|
89
|
+
})
|
|
90
|
+
.catch((error: unknown) =>
|
|
91
|
+
log.warn("App event outbox check failed", { appId, error: error instanceof Error ? error.message : String(error) }),
|
|
92
|
+
);
|
|
93
|
+
}, 60_000);
|
|
94
|
+
staleCheck.unref();
|
|
95
|
+
return async () => {
|
|
96
|
+
clearInterval(staleCheck);
|
|
97
|
+
dispatchers.set(appId, null);
|
|
98
|
+
await outbox.stop();
|
|
99
|
+
};
|
|
100
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AppEventDeliveryContext,
|
|
3
|
+
type AppEventEnvelope,
|
|
4
|
+
AppEventEnvelopeSchema,
|
|
5
|
+
appEventSubscriptionId,
|
|
6
|
+
} from "../contracts/app-events";
|
|
7
|
+
import type { CapabilityDefinitions } from "../contracts/capabilities";
|
|
8
|
+
import {
|
|
9
|
+
type AppEventPublishInput,
|
|
10
|
+
appEventBinding,
|
|
11
|
+
bindAppEvents,
|
|
12
|
+
buildAppEventEnvelope,
|
|
13
|
+
deliverAppEvent as deliverCompiledAppEvent,
|
|
14
|
+
} from "./app-events";
|
|
15
|
+
|
|
16
|
+
type EventKey<T extends CapabilityDefinitions> = Extract<keyof NonNullable<T["events"]>, string>;
|
|
17
|
+
|
|
18
|
+
/** Producer fixture, validated exactly like publish. A standalone test may supply its app ID explicitly. */
|
|
19
|
+
export const appEventFixture = <const T extends CapabilityDefinitions, Key extends EventKey<T>>(
|
|
20
|
+
capabilities: T,
|
|
21
|
+
localName: Key,
|
|
22
|
+
input: AppEventPublishInput<T, Key>,
|
|
23
|
+
options: { id?: string; time?: string; appId?: string } = {},
|
|
24
|
+
): AppEventEnvelope => {
|
|
25
|
+
if (options.appId !== undefined) bindAppEvents(options.appId, capabilities);
|
|
26
|
+
return buildAppEventEnvelope(capabilities, localName, input, {
|
|
27
|
+
id: options.id ?? "00000000-0000-4000-8000-000000000001",
|
|
28
|
+
time: options.time ?? "2026-01-01T00:00:00.000Z",
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Exercises the framework's real delivery validation and the declared subscriber. */
|
|
33
|
+
export const deliverAppEvent = async (
|
|
34
|
+
capabilities: CapabilityDefinitions,
|
|
35
|
+
eventType: string,
|
|
36
|
+
envelope: unknown,
|
|
37
|
+
context: Partial<AppEventDeliveryContext> & { appId?: string } = {},
|
|
38
|
+
): Promise<void> => {
|
|
39
|
+
if (context.appId !== undefined) bindAppEvents(context.appId, capabilities);
|
|
40
|
+
const { appId, compiled } = appEventBinding(capabilities);
|
|
41
|
+
const parsed = AppEventEnvelopeSchema.parse(envelope);
|
|
42
|
+
if (parsed.type !== eventType) throw new Error("App event type does not match the tested subscription");
|
|
43
|
+
const subscriptionId = appEventSubscriptionId(appId, eventType, parsed.version);
|
|
44
|
+
await deliverCompiledAppEvent({
|
|
45
|
+
compiled,
|
|
46
|
+
subscriptionId,
|
|
47
|
+
envelope,
|
|
48
|
+
context: {
|
|
49
|
+
subscriptionId: context.subscriptionId ?? subscriptionId,
|
|
50
|
+
eventId: context.eventId ?? parsed.id,
|
|
51
|
+
attempt: context.attempt ?? 1,
|
|
52
|
+
signal: context.signal ?? new AbortController().signal,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Explicit app ID for standalone declarations that have no defineApp binding. */
|
|
58
|
+
export const createAppEventTestHarness = (appId: string, capabilities: CapabilityDefinitions) => {
|
|
59
|
+
bindAppEvents(appId, capabilities);
|
|
60
|
+
return {
|
|
61
|
+
manifest: appEventBinding(capabilities).compiled.manifest,
|
|
62
|
+
deliver: (envelope: AppEventEnvelope, context: Partial<AppEventDeliveryContext> = {}): Promise<void> =>
|
|
63
|
+
deliverAppEvent(capabilities, envelope.type, envelope, context),
|
|
64
|
+
};
|
|
65
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import type { SQL } from "bun";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { assertAppEventSize } from "../_internal/app-event-schemas";
|
|
5
|
+
import { type CompiledCapabilities, compileCapabilities } from "../_internal/capabilities";
|
|
6
|
+
import { capabilityHash } from "../_internal/capability-schemas";
|
|
7
|
+
import {
|
|
8
|
+
type AppEventDeliveryContext,
|
|
9
|
+
type AppEventEnvelope,
|
|
10
|
+
AppEventEnvelopeSchema,
|
|
11
|
+
appEventSubscriptionId,
|
|
12
|
+
} from "../contracts/app-events";
|
|
13
|
+
import { CapabilityAppIdSchema, type CapabilityDefinitions } from "../contracts/capabilities";
|
|
14
|
+
import { registerAppEventPublisher, wakeAppEventDelivery } from "./app-event-delivery";
|
|
15
|
+
|
|
16
|
+
type EventBinding = { appId: string; compiled: CompiledCapabilities };
|
|
17
|
+
const bindings = new WeakMap<CapabilityDefinitions, EventBinding>();
|
|
18
|
+
const bindingsByApp = new Map<string, EventBinding>();
|
|
19
|
+
|
|
20
|
+
/** Framework hook: bind the exact declaration object when defineApp assigns its app ID. */
|
|
21
|
+
export const bindAppEvents = (appId: string, definitions: CapabilityDefinitions, compiledInput?: CompiledCapabilities): void => {
|
|
22
|
+
CapabilityAppIdSchema.parse(appId);
|
|
23
|
+
const prior = bindings.get(definitions);
|
|
24
|
+
if (prior && prior.appId !== appId) throw new Error(`Capabilities are already bound to ${prior.appId}`);
|
|
25
|
+
const compiled = compiledInput ?? prior?.compiled ?? compileCapabilities(appId, definitions);
|
|
26
|
+
const binding = { appId, compiled };
|
|
27
|
+
bindings.set(definitions, binding);
|
|
28
|
+
bindingsByApp.set(appId, binding);
|
|
29
|
+
if (compiled.events.size > 0) registerAppEventPublisher(appId);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const appEventBinding = (definitions: CapabilityDefinitions | string) => {
|
|
33
|
+
const binding = typeof definitions === "string" ? bindingsByApp.get(definitions) : bindings.get(definitions);
|
|
34
|
+
if (!binding)
|
|
35
|
+
throw new Error("Bind event capabilities with defineApp before publishing or testing; a test harness accepts an explicit appId");
|
|
36
|
+
return binding;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type EventDeclarations<T extends CapabilityDefinitions> = NonNullable<T["events"]>;
|
|
40
|
+
export type AppEventPublishInput<T extends CapabilityDefinitions, Key extends keyof EventDeclarations<T>> = {
|
|
41
|
+
subject: string;
|
|
42
|
+
data: z.input<EventDeclarations<T>[Key]["schema"]>;
|
|
43
|
+
requestId?: string;
|
|
44
|
+
} & (EventDeclarations<T>[Key] extends { place: string } ? { place: string } : { place?: never });
|
|
45
|
+
|
|
46
|
+
/** The same declaration, JSON and byte validation for publishers and producer fixtures. */
|
|
47
|
+
export const buildAppEventEnvelope = <const T extends CapabilityDefinitions, Key extends Extract<keyof EventDeclarations<T>, string>>(
|
|
48
|
+
definitions: T | string,
|
|
49
|
+
localId: Key,
|
|
50
|
+
input: AppEventPublishInput<T, Key>,
|
|
51
|
+
identity: { id: string; time: string },
|
|
52
|
+
): AppEventEnvelope => {
|
|
53
|
+
const binding = appEventBinding(definitions);
|
|
54
|
+
const event = binding.compiled.events.get(localId);
|
|
55
|
+
if (!event) throw new Error(`Unknown app event ${localId}`);
|
|
56
|
+
if ((event.manifest.place !== undefined) !== (input.place !== undefined)) {
|
|
57
|
+
throw new Error(`Event ${localId} place does not match its declaration`);
|
|
58
|
+
}
|
|
59
|
+
const data = z.json().parse(input.data);
|
|
60
|
+
if (!isDeepStrictEqual(input.data, JSON.parse(JSON.stringify(data)))) {
|
|
61
|
+
throw new Error("Event data must round-trip through JSON without normalization");
|
|
62
|
+
}
|
|
63
|
+
const parsed = event.definition.schema.parse(data);
|
|
64
|
+
if (capabilityHash(parsed) !== capabilityHash(data)) throw new Error("Event schema must preserve the original JSON data");
|
|
65
|
+
const envelope = AppEventEnvelopeSchema.parse({
|
|
66
|
+
...identity,
|
|
67
|
+
type: `${binding.appId}.${localId}`,
|
|
68
|
+
version: event.manifest.version,
|
|
69
|
+
source: binding.appId,
|
|
70
|
+
subject: { type: `${binding.appId}.${event.manifest.subject}`, id: input.subject },
|
|
71
|
+
...(event.manifest.place !== undefined ? { place: { type: `${binding.appId}.${event.manifest.place}`, id: input.place } } : {}),
|
|
72
|
+
...(input.requestId !== undefined ? { cause: { requestId: input.requestId } } : {}),
|
|
73
|
+
data,
|
|
74
|
+
});
|
|
75
|
+
assertAppEventSize(envelope);
|
|
76
|
+
return envelope;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Writes in the domain transaction; wake only after commit. Delivery is at least once. */
|
|
80
|
+
export const appEvents = <const T extends CapabilityDefinitions>(definitions: T | string) => ({
|
|
81
|
+
publish: async <Key extends Extract<keyof EventDeclarations<T>, string>>(
|
|
82
|
+
tx: SQL,
|
|
83
|
+
localId: Key,
|
|
84
|
+
input: AppEventPublishInput<T, Key>,
|
|
85
|
+
): Promise<AppEventEnvelope> => {
|
|
86
|
+
const envelope = buildAppEventEnvelope<T, Key>(definitions, localId, input, {
|
|
87
|
+
id: crypto.randomUUID(),
|
|
88
|
+
time: new Date().toISOString(),
|
|
89
|
+
});
|
|
90
|
+
// A full valid resource ref can exceed the existing outbox's 600-character key bound.
|
|
91
|
+
const orderingKey = capabilityHash(envelope.subject);
|
|
92
|
+
await tx`SELECT events.enqueue(${envelope.id}::uuid, ${envelope.source}, 'event', ${orderingKey}, ${JSON.stringify(envelope)}::text::jsonb)`;
|
|
93
|
+
return envelope;
|
|
94
|
+
},
|
|
95
|
+
wake: (): void => {
|
|
96
|
+
const binding = typeof definitions === "string" ? bindingsByApp.get(definitions) : bindings.get(definitions);
|
|
97
|
+
if (binding) wakeAppEventDelivery(binding.appId);
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/** Shared by the framework route and subscription tests; validates before executing application code. */
|
|
102
|
+
export const deliverAppEvent = async (params: {
|
|
103
|
+
compiled: CompiledCapabilities;
|
|
104
|
+
subscriptionId: string;
|
|
105
|
+
envelope: unknown;
|
|
106
|
+
context: AppEventDeliveryContext;
|
|
107
|
+
}): Promise<void> => {
|
|
108
|
+
const envelope = AppEventEnvelopeSchema.parse(params.envelope);
|
|
109
|
+
assertAppEventSize(envelope);
|
|
110
|
+
if (
|
|
111
|
+
!envelope.type.startsWith(`${envelope.source}.`) ||
|
|
112
|
+
!envelope.subject.type.startsWith(`${envelope.source}.`) ||
|
|
113
|
+
(envelope.place && !envelope.place.type.startsWith(`${envelope.source}.`))
|
|
114
|
+
)
|
|
115
|
+
throw new Error("App event source does not match its namespaced types");
|
|
116
|
+
if (envelope.place) throw new Error("resource_events_unsupported");
|
|
117
|
+
const subscription = params.compiled.subscribes.get(envelope.type);
|
|
118
|
+
if (!subscription || subscription.manifest.version !== envelope.version) {
|
|
119
|
+
throw new Error("App event subscription not found for type and version");
|
|
120
|
+
}
|
|
121
|
+
if (params.subscriptionId !== appEventSubscriptionId(params.compiled.manifest.appId, envelope.type, envelope.version)) {
|
|
122
|
+
throw new Error("App event subscription ID mismatch");
|
|
123
|
+
}
|
|
124
|
+
if (
|
|
125
|
+
params.context.subscriptionId !== params.subscriptionId ||
|
|
126
|
+
params.context.eventId !== envelope.id ||
|
|
127
|
+
!Number.isSafeInteger(params.context.attempt) ||
|
|
128
|
+
params.context.attempt < 1
|
|
129
|
+
)
|
|
130
|
+
throw new Error("App event delivery context mismatch");
|
|
131
|
+
params.context.signal.throwIfAborted();
|
|
132
|
+
const data = subscription.definition.schema.parse(envelope.data);
|
|
133
|
+
await subscription.definition.run({ ...envelope, data }, params.context);
|
|
134
|
+
};
|