@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,44 @@
|
|
|
1
|
+
import { type ToastHandle, toast } from "@k2b/ui";
|
|
2
|
+
import { appAccessMessages } from "../access/app-access-messages";
|
|
3
|
+
import { APP_ACCESS_REVOKED_EVENT } from "./live-websocket";
|
|
4
|
+
import { reloadOnce } from "./reload";
|
|
5
|
+
|
|
6
|
+
/** Keep an already-open page coherent when its next request or live connection loses app access. */
|
|
7
|
+
export const installAppAccessRecovery = (app: { id: string; routes: readonly string[] }): (() => void) => {
|
|
8
|
+
let active = true;
|
|
9
|
+
let notice: ToastHandle | undefined;
|
|
10
|
+
const recover = () => {
|
|
11
|
+
if (!active) return;
|
|
12
|
+
const url = new URL(window.location.href);
|
|
13
|
+
url.searchParams.set("_cloud_access", "revoked");
|
|
14
|
+
window.history.replaceState(window.history.state, "", url.pathname + url.search + url.hash);
|
|
15
|
+
if (!reloadOnce(`app-access:${app.id}`) && !notice) {
|
|
16
|
+
const t = appAccessMessages.resolve([document.documentElement.lang]).t;
|
|
17
|
+
notice = toast(t.accessChanged, { duration: 0, action: { label: t.reload, onClick: () => window.location.reload() } });
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const original = window.fetch;
|
|
21
|
+
const wrapped: typeof window.fetch = Object.assign(async (...args: Parameters<typeof window.fetch>) => {
|
|
22
|
+
const response = await original.apply(window, args);
|
|
23
|
+
if (response.status === 403 && response.headers.get("X-Cloud-App-Access-Denied") === app.id) {
|
|
24
|
+
const input = args[0];
|
|
25
|
+
const url = new URL(input instanceof Request ? input.url : String(input), window.location.href);
|
|
26
|
+
if (url.origin === window.location.origin) recover();
|
|
27
|
+
}
|
|
28
|
+
return response;
|
|
29
|
+
}, original);
|
|
30
|
+
window.fetch = wrapped;
|
|
31
|
+
const revoked = (event: Event) => {
|
|
32
|
+
if (!(event instanceof CustomEvent) || typeof event.detail !== "string") return;
|
|
33
|
+
const url = new URL(event.detail, window.location.href);
|
|
34
|
+
if (url.host !== window.location.host || !["ws:", "wss:"].includes(url.protocol)) return;
|
|
35
|
+
if (app.routes.some((prefix) => url.pathname === prefix || url.pathname.startsWith(`${prefix.replace(/\/$/, "")}/`))) recover();
|
|
36
|
+
};
|
|
37
|
+
window.addEventListener(APP_ACCESS_REVOKED_EVENT, revoked);
|
|
38
|
+
return () => {
|
|
39
|
+
active = false;
|
|
40
|
+
notice?.dismiss();
|
|
41
|
+
if (window.fetch === wrapped) window.fetch = original;
|
|
42
|
+
window.removeEventListener(APP_ACCESS_REVOKED_EVENT, revoked);
|
|
43
|
+
};
|
|
44
|
+
};
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { PWA_AUTH_PATH, PWA_SCOPE } from "../contracts/pwa-paths";
|
|
2
|
+
import { pwaDrafts } from "./pwa-drafts";
|
|
2
3
|
|
|
3
4
|
export type AppSessionRenewal = {
|
|
4
5
|
/** The page has a valid app session now. */
|
|
5
6
|
ok: boolean;
|
|
7
|
+
/** Registered release identities of mobile page owners and Core shared assets. */
|
|
8
|
+
builds?: Record<string, string>;
|
|
6
9
|
/** Android shares Chrome's cookies with the app, so the web session there may belong to another account. */
|
|
7
10
|
otherAccount?: { name: string };
|
|
8
11
|
};
|
|
@@ -26,6 +29,7 @@ const call = async (): Promise<AppSessionRenewal & { renewed: boolean }> => {
|
|
|
26
29
|
if (response.status === 401) {
|
|
27
30
|
// The phone was removed or its pairing ended. Core has already deleted the device key with this answer, so
|
|
28
31
|
// a reload could only show the generic pairing view; the pairing view for a signed-out phone says why.
|
|
32
|
+
pwaDrafts.clearAll();
|
|
29
33
|
location.replace(`${PWA_SCOPE}?pwa=ended`);
|
|
30
34
|
return { ok: false, renewed: false };
|
|
31
35
|
}
|
|
@@ -45,7 +49,11 @@ const call = async (): Promise<AppSessionRenewal & { renewed: boolean }> => {
|
|
|
45
49
|
const other = "otherAccount" in body ? body.otherAccount : undefined;
|
|
46
50
|
const otherAccount =
|
|
47
51
|
typeof other === "object" && other !== null && "name" in other && typeof other.name === "string" ? { name: other.name } : undefined;
|
|
48
|
-
|
|
52
|
+
const builds =
|
|
53
|
+
"builds" in body && typeof body.builds === "object" && body.builds !== null
|
|
54
|
+
? Object.fromEntries(Object.entries(body.builds).filter((entry): entry is [string, string] => typeof entry[1] === "string"))
|
|
55
|
+
: undefined;
|
|
56
|
+
return { ok: true, renewed: body.renewed, otherAccount, builds };
|
|
49
57
|
};
|
|
50
58
|
|
|
51
59
|
let running: Promise<AppSessionRenewal> | undefined;
|
|
@@ -59,8 +67,11 @@ export const renewAppSession = (): Promise<AppSessionRenewal> => {
|
|
|
59
67
|
try {
|
|
60
68
|
const first = await call();
|
|
61
69
|
// After a rotation, a second call presents the new key and retires the old one.
|
|
62
|
-
if (first.renewed)
|
|
63
|
-
|
|
70
|
+
if (first.renewed) {
|
|
71
|
+
const confirmed = await call();
|
|
72
|
+
if (!confirmed.ok) return confirmed;
|
|
73
|
+
}
|
|
74
|
+
return { ok: first.ok, otherAccount: first.otherAccount, builds: first.builds };
|
|
64
75
|
} finally {
|
|
65
76
|
running = undefined;
|
|
66
77
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { i18n } from "@k2b/stdlib";
|
|
2
|
+
import { type DropdownItem, toast, useLocale } from "@k2b/ui";
|
|
3
|
+
import { createMemo, onMount } from "solid-js";
|
|
4
|
+
import type { CloudResourceRef } from "../contracts/capabilities";
|
|
5
|
+
import { cloudResourceHref } from "../contracts/resource-href";
|
|
6
|
+
import { cloudOrigin } from "./resource-links";
|
|
7
|
+
|
|
8
|
+
export const cloudIdMessages = i18n.define({
|
|
9
|
+
baseLocale: "en",
|
|
10
|
+
messages: {
|
|
11
|
+
en: {
|
|
12
|
+
copy: "Copy Cloud ID",
|
|
13
|
+
copied: "Cloud ID copied",
|
|
14
|
+
copyFailed: "Could not copy the Cloud ID",
|
|
15
|
+
},
|
|
16
|
+
de: {
|
|
17
|
+
copy: "Cloud-ID kopieren",
|
|
18
|
+
copied: "Cloud-ID kopiert",
|
|
19
|
+
copyFailed: "Cloud-ID konnte nicht kopiert werden",
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
type ClipboardModule = typeof import("./resource-clipboard");
|
|
25
|
+
// The clipboard contract validates with Zod, which stays out of an island's first load. It loads once the page is
|
|
26
|
+
// idle, so a copy usually writes the structured reference inside the click that asked for it.
|
|
27
|
+
let clipboardModule: ClipboardModule | undefined;
|
|
28
|
+
const loadClipboard = () => import("./resource-clipboard").then((module) => (clipboardModule = module));
|
|
29
|
+
|
|
30
|
+
/** One entry of the menu; `label` names the resource when a menu offers several, such as a message and its conversation. */
|
|
31
|
+
export type CloudIdTarget = { ref: CloudResourceRef; label?: string };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* "Copy Cloud ID" for the "…" menu of a resource. It copies the structured reference and its `/open` permalink as
|
|
35
|
+
* text: Cloud paste surfaces recognize the resource, and anywhere else the text opens it after sign-in. Without a
|
|
36
|
+
* configured Cloud URL the menu shows nothing.
|
|
37
|
+
*/
|
|
38
|
+
export const useCloudIdCopy = () => {
|
|
39
|
+
const locale = useLocale();
|
|
40
|
+
const t = createMemo(() => cloudIdMessages.resolve([locale()]).t);
|
|
41
|
+
onMount(() => {
|
|
42
|
+
if (clipboardModule || !cloudOrigin()) return;
|
|
43
|
+
const warm = () => void loadClipboard().catch(() => undefined);
|
|
44
|
+
if (typeof requestIdleCallback === "function") requestIdleCallback(warm, { timeout: 2000 });
|
|
45
|
+
else setTimeout(warm, 0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const copy = (ref: CloudResourceRef) => {
|
|
49
|
+
const cloudUrl = cloudOrigin();
|
|
50
|
+
if (!cloudUrl) return;
|
|
51
|
+
const failed = () => void toast.error(t().copyFailed);
|
|
52
|
+
const write = (module: ClipboardModule) => {
|
|
53
|
+
try {
|
|
54
|
+
const { cloudResourceClipboard } = module;
|
|
55
|
+
return cloudResourceClipboard
|
|
56
|
+
.write({ cloudUrl, ref, fallbackText: cloudResourceClipboard.url(ref, cloudUrl) })
|
|
57
|
+
.then(() => void toast.success(t().copied), failed);
|
|
58
|
+
} catch {
|
|
59
|
+
failed();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
if (clipboardModule) return void write(clipboardModule);
|
|
63
|
+
// Before the clipboard code loads, copy the permalink alone: WebKit allows a write only while the click is handled.
|
|
64
|
+
try {
|
|
65
|
+
void navigator.clipboard.writeText(new URL(cloudResourceHref(ref), cloudUrl).href).then(() => void toast.success(t().copied), failed);
|
|
66
|
+
} catch {
|
|
67
|
+
failed();
|
|
68
|
+
}
|
|
69
|
+
void loadClipboard().catch(() => undefined);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
available: () => Boolean(cloudOrigin()),
|
|
74
|
+
copy,
|
|
75
|
+
/** The last section of a resource's "…" menu; empty without a resource or a configured Cloud URL. */
|
|
76
|
+
section: (...targets: readonly (CloudResourceRef | CloudIdTarget | null | undefined | false)[]): DropdownItem[] => {
|
|
77
|
+
if (!cloudOrigin()) return [];
|
|
78
|
+
const items = targets.flatMap((target) => {
|
|
79
|
+
if (!target) return [];
|
|
80
|
+
const { ref, label } = "ref" in target ? target : { ref: target, label: undefined };
|
|
81
|
+
return [{ label: label ?? t().copy, icon: "ti ti-id", immediate: true, action: () => copy(ref) }];
|
|
82
|
+
});
|
|
83
|
+
return items.length > 0 ? [{ items }] : [];
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
};
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
/** Transport event; the shell owns its visible recovery without coupling this client to UI. */
|
|
2
|
+
export const APP_ACCESS_REVOKED_EVENT = "cloud:app-access-revoked";
|
|
3
|
+
|
|
1
4
|
export type LiveWebSocketActivity = "always" | "visible";
|
|
2
5
|
export type LiveWebSocketStatus = "connecting" | "open" | "reconnecting" | "paused" | "closed";
|
|
3
6
|
|
|
@@ -264,6 +267,8 @@ export const createLiveWebSocket = <TMessage>(options: LiveWebSocketOptions<TMes
|
|
|
264
267
|
socket = null;
|
|
265
268
|
clearConnectDeadline();
|
|
266
269
|
if (disposed || terminated) return;
|
|
270
|
+
if (event.code === 1008 && event.reason === "app_access_denied")
|
|
271
|
+
window.dispatchEvent(new CustomEvent(APP_ACCESS_REVOKED_EVENT, { detail: next.url }));
|
|
267
272
|
const closeError = classifyClose({ code: event.code, reason: event.reason.trim() });
|
|
268
273
|
if (closeError) {
|
|
269
274
|
fatal(closeError, { code: event.code, reason: event.reason });
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Plain typed text only. Account and field identifiers belong in keys, never in stored payloads. */
|
|
2
|
+
const PREFIX = "cloud.pwa.draft:";
|
|
3
|
+
const key = (accountId: string, fieldId: string) => `${PREFIX}${encodeURIComponent(accountId)}:${encodeURIComponent(fieldId)}`;
|
|
4
|
+
// A denied/full store must still protect this document from an automatic reload while typing.
|
|
5
|
+
const pending = new Map<string, string>();
|
|
6
|
+
|
|
7
|
+
export const pwaDrafts = {
|
|
8
|
+
read(accountId: string, fieldId: string): string | undefined {
|
|
9
|
+
const id = key(accountId, fieldId);
|
|
10
|
+
try {
|
|
11
|
+
return pending.get(id) ?? window.localStorage.getItem(id) ?? undefined;
|
|
12
|
+
} catch {
|
|
13
|
+
return pending.get(id);
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
/** Call on each input, including empty text. False means the browser could not keep the draft across restarts. */
|
|
17
|
+
write(accountId: string, fieldId: string, text: string): boolean {
|
|
18
|
+
const id = key(accountId, fieldId);
|
|
19
|
+
pending.set(id, text);
|
|
20
|
+
try {
|
|
21
|
+
window.localStorage.setItem(id, text);
|
|
22
|
+
pending.delete(id);
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
/** Call only after saving/sending succeeds, or the person explicitly discards the draft. */
|
|
29
|
+
remove(accountId: string, fieldId: string): void {
|
|
30
|
+
const id = key(accountId, fieldId);
|
|
31
|
+
pending.delete(id);
|
|
32
|
+
try {
|
|
33
|
+
window.localStorage.removeItem(id);
|
|
34
|
+
} catch {}
|
|
35
|
+
},
|
|
36
|
+
has(accountId: string): boolean {
|
|
37
|
+
const prefix = `${PREFIX}${encodeURIComponent(accountId)}:`;
|
|
38
|
+
if ([...pending.keys()].some((id) => id.startsWith(prefix))) return true;
|
|
39
|
+
try {
|
|
40
|
+
const storage = window.localStorage;
|
|
41
|
+
return Array.from({ length: storage.length }, (_, i) => storage.key(i)).some((id) => id?.startsWith(prefix));
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
/** Sign-out, a removed device, and a new pairing erase every account's app drafts on this browser. */
|
|
47
|
+
clearAll(): void {
|
|
48
|
+
pending.clear();
|
|
49
|
+
try {
|
|
50
|
+
const storage = window.localStorage;
|
|
51
|
+
for (let i = storage.length - 1; i >= 0; i--) {
|
|
52
|
+
const id = storage.key(i);
|
|
53
|
+
if (id?.startsWith(PREFIX)) storage.removeItem(id);
|
|
54
|
+
}
|
|
55
|
+
} catch {}
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -3,7 +3,9 @@ import {
|
|
|
3
3
|
CLOUD_RESOURCE_CLIPBOARD_MAX_BYTES,
|
|
4
4
|
CLOUD_RESOURCE_CLIPBOARD_MIME_TYPE,
|
|
5
5
|
CLOUD_RESOURCE_CLIPBOARD_WEB_FORMAT,
|
|
6
|
+
formatCloudResourceUrl,
|
|
6
7
|
parseCloudResourceClipboard,
|
|
8
|
+
parseCloudResourceUrl,
|
|
7
9
|
serializeCloudResourceClipboard,
|
|
8
10
|
} from "../contracts/resource-clipboard";
|
|
9
11
|
|
|
@@ -65,6 +67,8 @@ export const cloudResourceClipboard = {
|
|
|
65
67
|
webFormat: CLOUD_RESOURCE_CLIPBOARD_WEB_FORMAT,
|
|
66
68
|
parse: parseCloudResourceClipboard,
|
|
67
69
|
serialize: serializeCloudResourceClipboard,
|
|
70
|
+
url: formatCloudResourceUrl,
|
|
71
|
+
parseUrl: parseCloudResourceUrl,
|
|
68
72
|
write: writeCloudResourceClipboard,
|
|
69
73
|
read: readCloudResourceClipboard,
|
|
70
74
|
} as const;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { CoreApiType } from "../api";
|
|
2
|
+
import { localCloudResourceUrl } from "../contracts/resource-pages";
|
|
3
|
+
import type { ResolvedCloudResourceUrl } from "../contracts/resource-resolution";
|
|
4
|
+
import { createApiClient } from "../server/api-client";
|
|
5
|
+
import { readShellData } from "../ssr/shell-data";
|
|
6
|
+
|
|
7
|
+
export type { ResolvedCloudResourceUrl } from "../contracts/resource-resolution";
|
|
8
|
+
|
|
9
|
+
/** Available on Layout and AdminLayout pages; MinimalLayout does not install shell data. */
|
|
10
|
+
export const cloudOrigin = (): string | null => readShellData()?.cloudOrigin ?? null;
|
|
11
|
+
|
|
12
|
+
/** Recognize one pasted Cloud link. Unsupported, unreadable, and unavailable links all return null. */
|
|
13
|
+
export const resolveCloudUrl = async (text: string, options: { signal?: AbortSignal } = {}): Promise<ResolvedCloudResourceUrl | null> => {
|
|
14
|
+
const url = text.trim();
|
|
15
|
+
if (!localCloudResourceUrl(url, cloudOrigin())) return null;
|
|
16
|
+
try {
|
|
17
|
+
const response = await createApiClient<CoreApiType>().resources.resolve.$post({ json: { url } }, { init: { signal: options.signal } });
|
|
18
|
+
if (!response.ok) return null;
|
|
19
|
+
// Validate only after a successful response; Zod stays out of the initial browser graph.
|
|
20
|
+
const { ResolvedCloudResourceUrlSchema } = await import("../contracts/resource-resolution");
|
|
21
|
+
const result = ResolvedCloudResourceUrlSchema.safeParse(await response.json());
|
|
22
|
+
return result.success ? result.data : null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Ctrl+Shift+V (Cmd+Shift+V, also with Option, which changes the key's character) asks for plain text; that paste
|
|
29
|
+
// keeps the link as it is.
|
|
30
|
+
let plainPasteAt = -Infinity;
|
|
31
|
+
if (typeof window !== "undefined")
|
|
32
|
+
window.addEventListener(
|
|
33
|
+
"keydown",
|
|
34
|
+
(event) => {
|
|
35
|
+
if (event.shiftKey && (event.ctrlKey || event.metaKey) && (event.code === "KeyV" || event.key.toLowerCase() === "v"))
|
|
36
|
+
plainPasteAt = event.timeStamp;
|
|
37
|
+
},
|
|
38
|
+
true,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The Cloud link a paste carries: one absolute URL on this Cloud, as copied from the address bar or "Copy Cloud ID",
|
|
43
|
+
* and nothing else. Returns null for other text, other origins, and a paste as plain text. Recognize the link with
|
|
44
|
+
* `resolveCloudUrl()`.
|
|
45
|
+
*/
|
|
46
|
+
export const pastedCloudLink = (event: ClipboardEvent): string | null => {
|
|
47
|
+
const plain = event.timeStamp - plainPasteAt < 1_000;
|
|
48
|
+
plainPasteAt = -Infinity;
|
|
49
|
+
const text = (event.clipboardData?.getData("text/plain") ?? "").trim();
|
|
50
|
+
if (plain || text.startsWith("/")) return null;
|
|
51
|
+
return localCloudResourceUrl(text, cloudOrigin()) ? text : null;
|
|
52
|
+
};
|
|
@@ -4,10 +4,11 @@ import type { CloudResourceRef } from "../contracts";
|
|
|
4
4
|
import CloudResourceSearch from "./CloudResourceSearch";
|
|
5
5
|
import { resourceSearchDialogOptions } from "./resource-search-dialog";
|
|
6
6
|
import { resourceSearchMessages } from "./resource-search-messages";
|
|
7
|
+
import type { SearchScope } from "./search-bridge";
|
|
7
8
|
|
|
8
9
|
export type CloudResourcePickerItem = SearchItem;
|
|
9
|
-
/** Narrows the search
|
|
10
|
-
export type CloudResourcePickerScope =
|
|
10
|
+
/** Narrows the server search to an app/tag or resource context; shown as a removable chip. */
|
|
11
|
+
export type CloudResourcePickerScope = SearchScope;
|
|
11
12
|
export type CloudResourcePickerOptions = {
|
|
12
13
|
title?: string;
|
|
13
14
|
placeholder?: string;
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
CALENDAR_SOURCE_MAX_RANGE_MS,
|
|
4
|
+
CALENDAR_SOURCE_PAGE_LIMIT,
|
|
5
|
+
CalendarEventsDataSchema,
|
|
6
|
+
CalendarListDataSchema,
|
|
7
|
+
calendarSourceIssues,
|
|
8
|
+
projectCalendarDescriptor,
|
|
9
|
+
projectCalendarItem,
|
|
10
|
+
} from "../contracts/calendar-source";
|
|
11
|
+
import {
|
|
12
|
+
type CalendarReadResult,
|
|
13
|
+
type CalendarSelection,
|
|
14
|
+
type CalendarSourceListEntry,
|
|
15
|
+
type CalendarSourceSummary,
|
|
16
|
+
CalendarViewRangeSchema,
|
|
17
|
+
} from "../contracts/calendar-view";
|
|
18
|
+
import { CapabilityAppIdSchema } from "../contracts/capabilities";
|
|
19
|
+
import { type CapabilityCaller, invokeCapabilityWithDataSchema, listCapabilityCatalog } from "./server-invoke";
|
|
20
|
+
import type { CapabilityCatalogApp, CapabilityClientError } from "./types";
|
|
21
|
+
|
|
22
|
+
export * from "../contracts/calendar-view";
|
|
23
|
+
|
|
24
|
+
// Interactive read budget: 60 requests, six in flight, 4 MiB of pages.
|
|
25
|
+
const MAX_CALLS = 60;
|
|
26
|
+
const MAX_BYTES = 4 * 1024 * 1024;
|
|
27
|
+
const CONCURRENCY = 6;
|
|
28
|
+
const SelectionSchema = z
|
|
29
|
+
.array(z.object({ appId: CapabilityAppIdSchema, calendarId: z.string().min(1).max(2048) }).strict())
|
|
30
|
+
.max(CALENDAR_SOURCE_PAGE_LIMIT);
|
|
31
|
+
|
|
32
|
+
class ReadStopped extends Error {
|
|
33
|
+
constructor(
|
|
34
|
+
readonly state: "pending" | "truncated" | "failed",
|
|
35
|
+
readonly code: string,
|
|
36
|
+
readonly retryable = true,
|
|
37
|
+
) {
|
|
38
|
+
super(code);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const failure = (error: CapabilityClientError) =>
|
|
42
|
+
new ReadStopped("failed", error.code, error.status === 429 || error.status >= 500 || error.status === 499);
|
|
43
|
+
|
|
44
|
+
/** One request only; no identity, catalog or source data survives this session. */
|
|
45
|
+
class ReadSession {
|
|
46
|
+
private calls = 0;
|
|
47
|
+
private bytes = 0;
|
|
48
|
+
private readonly controller = new AbortController();
|
|
49
|
+
private readonly timer: ReturnType<typeof setTimeout>;
|
|
50
|
+
private readonly upstream: AbortSignal[];
|
|
51
|
+
private stop: ReadStopped | undefined;
|
|
52
|
+
readonly credentials: CapabilityCaller;
|
|
53
|
+
constructor(credentials: CapabilityCaller, deadline: number, signal?: AbortSignal) {
|
|
54
|
+
if (!Number.isFinite(deadline)) throw new RangeError("Calendar deadline must be a finite epoch time in milliseconds");
|
|
55
|
+
this.credentials = { ...credentials, signal: this.controller.signal };
|
|
56
|
+
if (!credentials.authority) {
|
|
57
|
+
this.credentials.transport = async (request) => {
|
|
58
|
+
const response = await (credentials.transport ?? fetch)(request);
|
|
59
|
+
if (!response.body) return response;
|
|
60
|
+
const body = response.body.pipeThrough(
|
|
61
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
62
|
+
transform: (chunk, controller) => {
|
|
63
|
+
this.addBytes(chunk.byteLength);
|
|
64
|
+
controller.enqueue(chunk);
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
{ signal: this.controller.signal },
|
|
68
|
+
);
|
|
69
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
this.upstream = [...new Set([credentials.signal, signal].filter((value): value is AbortSignal => value !== undefined))];
|
|
73
|
+
for (const upstream of this.upstream) upstream.addEventListener("abort", this.abort, { once: true });
|
|
74
|
+
this.timer = setTimeout(() => this.halt(new ReadStopped("pending", "CALENDAR_DEADLINE")), Math.max(0, deadline - Date.now()));
|
|
75
|
+
if (this.upstream.some((upstream) => upstream.aborted)) this.abort();
|
|
76
|
+
else if (deadline <= Date.now()) this.halt(new ReadStopped("pending", "CALENDAR_DEADLINE"));
|
|
77
|
+
}
|
|
78
|
+
private readonly abort = () => this.halt(new ReadStopped("pending", "REQUEST_CANCELLED"));
|
|
79
|
+
private halt(reason: ReadStopped) {
|
|
80
|
+
this.stop ??= reason;
|
|
81
|
+
this.controller.abort();
|
|
82
|
+
}
|
|
83
|
+
check() {
|
|
84
|
+
if (this.stop) throw this.stop;
|
|
85
|
+
}
|
|
86
|
+
private addBytes(bytes: number) {
|
|
87
|
+
this.bytes += bytes;
|
|
88
|
+
if (this.bytes > MAX_BYTES) {
|
|
89
|
+
this.halt(new ReadStopped("truncated", "CALENDAR_BYTE_BUDGET"));
|
|
90
|
+
this.check();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async page<T>(read: () => Promise<T>): Promise<T> {
|
|
94
|
+
this.check();
|
|
95
|
+
if (this.calls >= MAX_CALLS) {
|
|
96
|
+
this.halt(new ReadStopped("truncated", "CALENDAR_CALL_BUDGET"));
|
|
97
|
+
this.check();
|
|
98
|
+
}
|
|
99
|
+
this.calls++;
|
|
100
|
+
let onAbort: (() => void) | undefined;
|
|
101
|
+
try {
|
|
102
|
+
const cancelled = new Promise<never>((_, reject) => {
|
|
103
|
+
onAbort = () => reject(this.stop ?? new ReadStopped("pending", "REQUEST_CANCELLED"));
|
|
104
|
+
this.controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
105
|
+
});
|
|
106
|
+
const result = await Promise.race([read(), cancelled]);
|
|
107
|
+
this.check();
|
|
108
|
+
// Core in-process authority has no transport body; count its serialized page instead.
|
|
109
|
+
if (this.credentials.authority) this.addBytes(new TextEncoder().encode(JSON.stringify(result)).byteLength);
|
|
110
|
+
return result;
|
|
111
|
+
} finally {
|
|
112
|
+
if (onAbort) this.controller.signal.removeEventListener("abort", onAbort);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
close() {
|
|
116
|
+
clearTimeout(this.timer);
|
|
117
|
+
for (const upstream of this.upstream) upstream.removeEventListener("abort", this.abort);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const catalog = async (session: ReadSession): Promise<CapabilityCatalogApp[]> => {
|
|
122
|
+
const apps: CapabilityCatalogApp[] = [];
|
|
123
|
+
let cursor: string | undefined;
|
|
124
|
+
const seen = new Set<string>();
|
|
125
|
+
do {
|
|
126
|
+
const page = await session.page(() => listCapabilityCatalog({ caller: session.credentials, cursor, limit: 25 }));
|
|
127
|
+
if (!page.ok) throw failure(page.error);
|
|
128
|
+
apps.push(...page.data.apps);
|
|
129
|
+
cursor = page.data.page.hasMore ? page.data.page.nextCursor : undefined;
|
|
130
|
+
if (cursor && seen.has(cursor)) throw new ReadStopped("failed", "CALENDAR_CURSOR_LOOP", false);
|
|
131
|
+
if (cursor) seen.add(cursor);
|
|
132
|
+
if (page.data.page.hasMore && !cursor) throw new ReadStopped("failed", "INVALID_APP_RESPONSE", false);
|
|
133
|
+
} while (cursor);
|
|
134
|
+
return apps.filter((app) => app.manifest.calendarSource && calendarSourceIssues(app.manifest).length === 0);
|
|
135
|
+
};
|
|
136
|
+
const workers = async <T>(values: T[], run: (value: T) => Promise<void>) => {
|
|
137
|
+
let index = 0;
|
|
138
|
+
await Promise.all(
|
|
139
|
+
Array.from({ length: Math.min(CONCURRENCY, values.length) }, async () => {
|
|
140
|
+
while (index < values.length) await run(values[index++]!);
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
const stopped = (error: unknown) => (error instanceof ReadStopped ? error : new ReadStopped("failed", "APP_UNAVAILABLE"));
|
|
145
|
+
|
|
146
|
+
/** Follow calendar pagination for the current caller. A partial list is explicitly failed. */
|
|
147
|
+
export const listCalendarSources = async (
|
|
148
|
+
credentials: CapabilityCaller,
|
|
149
|
+
options: { deadline: number; signal?: AbortSignal },
|
|
150
|
+
): Promise<CalendarSourceListEntry[]> => {
|
|
151
|
+
const session = new ReadSession(credentials, options.deadline, options.signal);
|
|
152
|
+
try {
|
|
153
|
+
const apps = await catalog(session);
|
|
154
|
+
const result = apps.map(
|
|
155
|
+
(app): CalendarSourceListEntry => ({
|
|
156
|
+
appId: app.appId,
|
|
157
|
+
name: app.appName,
|
|
158
|
+
icon: app.appIcon ?? null,
|
|
159
|
+
state: "ok",
|
|
160
|
+
error: null,
|
|
161
|
+
calendars: [],
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
await workers(apps, async (app) => {
|
|
165
|
+
const source = result.find((source) => source.appId === app.appId)!;
|
|
166
|
+
try {
|
|
167
|
+
let cursor: string | undefined;
|
|
168
|
+
const seen = new Set<string>();
|
|
169
|
+
const ids = new Set<string>();
|
|
170
|
+
do {
|
|
171
|
+
const page = await session.page(() =>
|
|
172
|
+
invokeCapabilityWithDataSchema(
|
|
173
|
+
{
|
|
174
|
+
appId: app.appId,
|
|
175
|
+
capabilityId: app.manifest.calendarSource!.calendars,
|
|
176
|
+
kind: "query",
|
|
177
|
+
input: { limit: CALENDAR_SOURCE_PAGE_LIMIT, ...(cursor ? { cursor } : {}) },
|
|
178
|
+
},
|
|
179
|
+
CalendarListDataSchema,
|
|
180
|
+
session.credentials,
|
|
181
|
+
),
|
|
182
|
+
);
|
|
183
|
+
if (!page.ok) throw failure(page.error);
|
|
184
|
+
for (const entry of page.data.data.items)
|
|
185
|
+
if (!ids.has(entry.id)) {
|
|
186
|
+
ids.add(entry.id);
|
|
187
|
+
if (source.calendars.length < CALENDAR_SOURCE_PAGE_LIMIT) source.calendars.push(projectCalendarDescriptor(entry));
|
|
188
|
+
}
|
|
189
|
+
cursor = page.data.data.next ?? undefined;
|
|
190
|
+
if (ids.size > CALENDAR_SOURCE_PAGE_LIMIT) throw new ReadStopped("truncated", "CALENDAR_CALENDAR_BUDGET");
|
|
191
|
+
if (cursor && seen.has(cursor)) throw new ReadStopped("failed", "CALENDAR_CURSOR_LOOP", false);
|
|
192
|
+
if (cursor) seen.add(cursor);
|
|
193
|
+
} while (cursor);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
const reason = stopped(error);
|
|
196
|
+
source.state = "failed";
|
|
197
|
+
source.error = { code: reason.code, retryable: reason.retryable };
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
return result;
|
|
201
|
+
} finally {
|
|
202
|
+
session.close();
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** Deadline is an absolute epoch millisecond time. onSource receives each source's final progress state. */
|
|
207
|
+
export const readCalendarEvents = async (options: {
|
|
208
|
+
credentials: CapabilityCaller;
|
|
209
|
+
calendars: CalendarSelection[];
|
|
210
|
+
from: string;
|
|
211
|
+
to: string;
|
|
212
|
+
signal?: AbortSignal;
|
|
213
|
+
deadline: number;
|
|
214
|
+
onSource?: (source: CalendarSourceSummary) => void;
|
|
215
|
+
}): Promise<CalendarReadResult> => {
|
|
216
|
+
const calendars = SelectionSchema.parse(options.calendars);
|
|
217
|
+
const { from, to } = CalendarViewRangeSchema.parse({ from: options.from, to: options.to });
|
|
218
|
+
const grouped = new Map<string, Set<string>>();
|
|
219
|
+
for (const { appId, calendarId } of calendars) {
|
|
220
|
+
const ids = grouped.get(appId) ?? new Set<string>();
|
|
221
|
+
ids.add(calendarId);
|
|
222
|
+
grouped.set(appId, ids);
|
|
223
|
+
}
|
|
224
|
+
const result: CalendarReadResult = { items: [], sources: [...grouped.keys()].map((appId) => ({ appId, state: "pending", error: null })) };
|
|
225
|
+
if (!calendars.length) return result;
|
|
226
|
+
const session = new ReadSession(options.credentials, options.deadline, options.signal);
|
|
227
|
+
const dedupe = new Set<string>();
|
|
228
|
+
try {
|
|
229
|
+
let apps: CapabilityCatalogApp[];
|
|
230
|
+
try {
|
|
231
|
+
apps = await catalog(session);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
const reason = stopped(error);
|
|
234
|
+
for (const source of result.sources) {
|
|
235
|
+
source.state = reason.state;
|
|
236
|
+
source.error = { code: reason.code, retryable: reason.retryable };
|
|
237
|
+
options.onSource?.({ ...source });
|
|
238
|
+
}
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
await workers(result.sources, async (source) => {
|
|
242
|
+
try {
|
|
243
|
+
session.check();
|
|
244
|
+
const app = apps.find((app) => app.appId === source.appId);
|
|
245
|
+
if (!app) throw new ReadStopped("failed", "CAPABILITY_NOT_FOUND", false);
|
|
246
|
+
for (let start = Date.parse(from); start < Date.parse(to); start += CALENDAR_SOURCE_MAX_RANGE_MS) {
|
|
247
|
+
const end = Math.min(start + CALENDAR_SOURCE_MAX_RANGE_MS, Date.parse(to));
|
|
248
|
+
let cursor: string | undefined;
|
|
249
|
+
const seen = new Set<string>();
|
|
250
|
+
do {
|
|
251
|
+
const page = await session.page(() =>
|
|
252
|
+
invokeCapabilityWithDataSchema(
|
|
253
|
+
{
|
|
254
|
+
appId: source.appId,
|
|
255
|
+
capabilityId: app.manifest.calendarSource!.events,
|
|
256
|
+
kind: "query",
|
|
257
|
+
input: {
|
|
258
|
+
calendarIds: [...grouped.get(source.appId)!],
|
|
259
|
+
from: new Date(start).toISOString(),
|
|
260
|
+
to: new Date(end).toISOString(),
|
|
261
|
+
limit: CALENDAR_SOURCE_PAGE_LIMIT,
|
|
262
|
+
...(cursor ? { cursor } : {}),
|
|
263
|
+
},
|
|
264
|
+
},
|
|
265
|
+
CalendarEventsDataSchema,
|
|
266
|
+
session.credentials,
|
|
267
|
+
),
|
|
268
|
+
);
|
|
269
|
+
if (!page.ok) throw failure(page.error);
|
|
270
|
+
for (const item of page.data.data.items) {
|
|
271
|
+
if (!grouped.get(source.appId)!.has(item.calendarId)) continue;
|
|
272
|
+
const key = JSON.stringify([source.appId, item.calendarId, item.id]);
|
|
273
|
+
if (dedupe.has(key)) continue;
|
|
274
|
+
dedupe.add(key);
|
|
275
|
+
result.items.push({ ...projectCalendarItem(item), appId: source.appId });
|
|
276
|
+
}
|
|
277
|
+
cursor = page.data.data.next ?? undefined;
|
|
278
|
+
if (cursor && seen.has(cursor)) throw new ReadStopped("failed", "CALENDAR_CURSOR_LOOP", false);
|
|
279
|
+
if (cursor) seen.add(cursor);
|
|
280
|
+
} while (cursor);
|
|
281
|
+
}
|
|
282
|
+
source.state = "ok";
|
|
283
|
+
} catch (error) {
|
|
284
|
+
const reason = stopped(error);
|
|
285
|
+
source.state = reason.state;
|
|
286
|
+
source.error = { code: reason.code, retryable: reason.retryable };
|
|
287
|
+
}
|
|
288
|
+
options.onSource?.({ ...source });
|
|
289
|
+
});
|
|
290
|
+
const point = (item: CalendarReadResult["items"][number]) => {
|
|
291
|
+
const time = item.type === "deadline" ? (item.due.kind === "date" ? item.due.date : item.due.at) : item.time.start;
|
|
292
|
+
return Date.parse(time);
|
|
293
|
+
};
|
|
294
|
+
result.items.sort(
|
|
295
|
+
(a, b) =>
|
|
296
|
+
point(a) - point(b) || a.appId.localeCompare(b.appId) || a.calendarId.localeCompare(b.calendarId) || a.id.localeCompare(b.id),
|
|
297
|
+
);
|
|
298
|
+
return result;
|
|
299
|
+
} finally {
|
|
300
|
+
session.close();
|
|
301
|
+
}
|
|
302
|
+
};
|