@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
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import {
|
|
2
|
+
import { canonicalRoute } from "../_internal/app-path";
|
|
3
|
+
import { readBoundedJson } from "../_internal/bounded-json";
|
|
4
|
+
import { resolveCapabilityManifestPresentation } from "../_internal/capabilities";
|
|
5
|
+
import { getCapability, listApps, listCapabilities } from "../_internal/registry";
|
|
3
6
|
import {
|
|
7
|
+
CAPABILITY_MAX_RESULT_BYTES,
|
|
4
8
|
CapabilitySemanticLinkSchema,
|
|
5
9
|
type CloudResourceRef,
|
|
6
10
|
CloudResourceRefSchema,
|
|
@@ -8,20 +12,26 @@ import {
|
|
|
8
12
|
cloudResourceRefAppId,
|
|
9
13
|
resolveCapabilityResourceReader,
|
|
10
14
|
} from "../contracts/capabilities";
|
|
15
|
+
import { parseCloudResourceUrl } from "../contracts/resource-clipboard";
|
|
16
|
+
import { localCloudResourceUrl, matchCloudResourcePage } from "../contracts/resource-pages";
|
|
17
|
+
import type { ResolvedCloudResourceUrl } from "../contracts/resource-resolution";
|
|
11
18
|
import type { RequestAuthority } from "../server";
|
|
19
|
+
import * as settings from "../services/settings";
|
|
20
|
+
import { publicCloudOrigin } from "../shared/app-url";
|
|
12
21
|
import { dispatchCapability } from "./capabilities";
|
|
13
22
|
|
|
14
|
-
|
|
23
|
+
const readCloudResourceLink = async (
|
|
15
24
|
params: { ref: CloudResourceRef; request: Request; authority: RequestAuthority; locale?: string },
|
|
16
25
|
dependencies: { getCapability?: typeof getCapability; dispatch?: typeof dispatchCapability } = {},
|
|
17
|
-
): Promise<
|
|
26
|
+
): Promise<ResolvedCloudResourceUrl | null> => {
|
|
18
27
|
const parsed = CloudResourceRefSchema.safeParse(params.ref);
|
|
19
28
|
if (!parsed.success) return null;
|
|
20
29
|
const ref = parsed.data;
|
|
21
30
|
const appId = cloudResourceRefAppId(ref);
|
|
22
31
|
const app = await (dependencies.getCapability ?? getCapability)(appId);
|
|
23
32
|
if (!app || app.appId !== appId) return null;
|
|
24
|
-
const
|
|
33
|
+
const manifest = params.locale ? resolveCapabilityManifestPresentation(app.manifest, app.presentation, params.locale) : app.manifest;
|
|
34
|
+
const reader = resolveCapabilityResourceReader(manifest, ref);
|
|
25
35
|
if (!reader) return null;
|
|
26
36
|
const response = await (dependencies.dispatch ?? dispatchCapability)({
|
|
27
37
|
request: params.request,
|
|
@@ -34,10 +44,77 @@ export const resolveCloudResourceHref = async (
|
|
|
34
44
|
input: { id: ref.id },
|
|
35
45
|
});
|
|
36
46
|
if (!response.ok) return null;
|
|
37
|
-
const
|
|
47
|
+
const body = await readBoundedJson(response, CAPABILITY_MAX_RESULT_BYTES, params.request.signal);
|
|
48
|
+
if (!body.ok) return null;
|
|
49
|
+
const result = capabilityResultSchema(z.unknown(), { consumer: true }).safeParse(body.data);
|
|
38
50
|
if (!result.success) return null;
|
|
39
51
|
const link = CapabilitySemanticLinkSchema.safeParse(
|
|
40
52
|
result.data.links?.find((link) => link.rel === "open") ?? result.data.links?.find((link) => link.rel === "edit"),
|
|
41
53
|
);
|
|
42
|
-
|
|
54
|
+
if (!link.success) return null;
|
|
55
|
+
const type = manifest.types.find((type) => `${appId}.${type.localId}` === ref.type);
|
|
56
|
+
if (!type) return null;
|
|
57
|
+
const reference = result.data.refs?.find((entry) => entry.type === ref.type && entry.id === ref.id);
|
|
58
|
+
const icon = reference?.icon ?? type.icon;
|
|
59
|
+
return { ref, title: reference?.title ?? type.title, ...(icon ? { icon } : {}), href: link.data.href };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Resolve a known reference through its authorized reader. */
|
|
63
|
+
export const resolveCloudResourceHref = async (
|
|
64
|
+
params: Parameters<typeof readCloudResourceLink>[0],
|
|
65
|
+
dependencies: Parameters<typeof readCloudResourceLink>[1] = {},
|
|
66
|
+
): Promise<string | null> => (await readCloudResourceLink(params, dependencies))?.href ?? null;
|
|
67
|
+
|
|
68
|
+
/** Recognize one local URL, then read the resource under the caller's authority. All failures are opaque. */
|
|
69
|
+
export const resolveCloudResourceUrl = async (
|
|
70
|
+
params: { url: string; request: Request; authority: RequestAuthority; locale?: string },
|
|
71
|
+
dependencies: Parameters<typeof readCloudResourceLink>[1] & {
|
|
72
|
+
appUrl?: () => Promise<string | null | undefined>;
|
|
73
|
+
listApps?: typeof listApps;
|
|
74
|
+
listCapabilities?: typeof listCapabilities;
|
|
75
|
+
} = {},
|
|
76
|
+
): Promise<ResolvedCloudResourceUrl | null> => {
|
|
77
|
+
try {
|
|
78
|
+
const configured = await (dependencies.appUrl ?? (() => settings.get<string>("app.url")))();
|
|
79
|
+
const origin = configured ? publicCloudOrigin(configured) : null;
|
|
80
|
+
const url = localCloudResourceUrl(params.url, origin);
|
|
81
|
+
if (!url) return null;
|
|
82
|
+
// Permalinks share the existing parser; fragments and incidental query parameters do not change identity.
|
|
83
|
+
let ref = parseCloudResourceUrl(`${url.pathname}`, origin ?? url.origin);
|
|
84
|
+
if (!ref && !url.pathname.startsWith("/open/")) {
|
|
85
|
+
const apps = await (dependencies.listApps ?? listApps)();
|
|
86
|
+
// Honor a more specific live owner, including apps that publish no resource pages.
|
|
87
|
+
// A root or ancestor route must not capture their links. Equal prefix claims fail closed.
|
|
88
|
+
const pathname = canonicalRoute(url.pathname);
|
|
89
|
+
const namespace = pathname.split("/");
|
|
90
|
+
const routes = apps.flatMap((app) =>
|
|
91
|
+
app.routes.flatMap((route) => {
|
|
92
|
+
const prefix = canonicalRoute(route);
|
|
93
|
+
if (!prefix.startsWith("/") || /[%\\?#\s]/.test(prefix) || prefix.split("/").some((part) => part === "." || part === ".."))
|
|
94
|
+
return [];
|
|
95
|
+
if (namespace[1] === "app" && namespace[2] && namespace[2] !== app.id) return [];
|
|
96
|
+
return prefix === "/" || pathname === prefix || pathname.startsWith(`${prefix}/`)
|
|
97
|
+
? [{ appId: app.id, length: prefix.length }]
|
|
98
|
+
: [];
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
const longest = routes.reduce((length, route) => Math.max(length, route.length), 0);
|
|
102
|
+
const owners = new Set(routes.filter((route) => route.length === longest).map((route) => route.appId));
|
|
103
|
+
if (owners.size !== 1) return null;
|
|
104
|
+
const catalog = await (dependencies.listCapabilities ?? listCapabilities)(apps);
|
|
105
|
+
const pages = catalog.flatMap((entry) => {
|
|
106
|
+
const app = apps.find((app) => app.id === entry.appId);
|
|
107
|
+
if (!app || !owners.has(app.id) || entry.manifest.appId !== app.id) return [];
|
|
108
|
+
return (entry.manifest.resourcePages ?? []).flatMap((page) => {
|
|
109
|
+
const ref = { type: `${app.id}.${page.type}`, id: "candidate" };
|
|
110
|
+
return resolveCapabilityResourceReader(entry.manifest, ref) ? [{ type: ref.type, path: page.path, prefixes: app.routes }] : [];
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
ref = matchCloudResourcePage(`${url.pathname}${url.search}`, pages);
|
|
114
|
+
}
|
|
115
|
+
return ref ? await readCloudResourceLink({ ...params, ref }, dependencies) : null;
|
|
116
|
+
} catch {
|
|
117
|
+
// Never include a pasted URL, resource existence, or provider error in this result.
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
43
120
|
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { describeRoute, resolver } from "hono-openapi";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { readBoundedJson } from "../_internal/bounded-json";
|
|
5
|
+
import { withCapabilityRequestTimeout } from "../_internal/capability-timeout";
|
|
6
|
+
import { RESOURCE_URL_MAX_LENGTH } from "../contracts/resource-pages";
|
|
7
|
+
import { ResolvedCloudResourceUrlSchema } from "../contracts/resource-resolution";
|
|
8
|
+
import { type AuthContext, auth, getLocale } from "../server";
|
|
9
|
+
import { resolveCloudResourceUrl } from "./resource-links";
|
|
10
|
+
|
|
11
|
+
const Input = z.object({ url: z.string().min(1).max(RESOURCE_URL_MAX_LENGTH) }).strict();
|
|
12
|
+
const NotFound = { error: "Resource not found" } as const;
|
|
13
|
+
|
|
14
|
+
export const createResourceResolveRoutes = (resolve = resolveCloudResourceUrl) =>
|
|
15
|
+
new Hono<AuthContext>().post(
|
|
16
|
+
"/resolve",
|
|
17
|
+
auth.readOnly,
|
|
18
|
+
auth.requireRole("authenticated"),
|
|
19
|
+
describeRoute({
|
|
20
|
+
tags: ["Resources"],
|
|
21
|
+
summary: "Resolve a Cloud URL through its authorized resource reader",
|
|
22
|
+
requestBody: { required: true, content: { "application/json": { schema: resolver(Input) } } },
|
|
23
|
+
responses: {
|
|
24
|
+
200: { description: "Readable resource", content: { "application/json": { schema: resolver(ResolvedCloudResourceUrlSchema) } } },
|
|
25
|
+
404: {
|
|
26
|
+
description: "Resource not found",
|
|
27
|
+
content: { "application/json": { schema: resolver(z.object({ error: z.literal(NotFound.error) })) } },
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
async (c) => {
|
|
32
|
+
try {
|
|
33
|
+
// JSON may encode each UTF-16 unit as six bytes. Bound the envelope before parsing.
|
|
34
|
+
const body = await readBoundedJson(c.req.raw, RESOURCE_URL_MAX_LENGTH * 6 + 100, c.req.raw.signal);
|
|
35
|
+
const input = body.ok ? Input.safeParse(body.data) : null;
|
|
36
|
+
if (!input?.success) return c.json(NotFound, 404);
|
|
37
|
+
const result = await withCapabilityRequestTimeout(c, "query", () =>
|
|
38
|
+
resolve({
|
|
39
|
+
url: input.data.url,
|
|
40
|
+
request: c.req.raw,
|
|
41
|
+
authority: auth.getAuthority(c),
|
|
42
|
+
locale: getLocale(c),
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
return result ? c.json(result, 200) : c.json(NotFound, 404);
|
|
46
|
+
} catch {
|
|
47
|
+
return c.json(NotFound, 404);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { CloudResourceRefSchema } from "../../contracts";
|
|
3
|
-
import { CapabilitySearchTagManifestSchema } from "../../contracts/capabilities";
|
|
3
|
+
import { CapabilitySearchTagManifestSchema, CloudResourceReferenceSchema } from "../../contracts/capabilities";
|
|
4
4
|
|
|
5
5
|
const TAG_PATTERN = /^[^\s#]+$/;
|
|
6
6
|
const SEARCH_BASE_URL = "https://cloud.invalid";
|
|
@@ -69,6 +69,8 @@ export const SearchItemSchema = z.object({
|
|
|
69
69
|
ref: CloudResourceRefSchema,
|
|
70
70
|
title: z.string(),
|
|
71
71
|
href: SameOriginPathSchema,
|
|
72
|
+
context: CloudResourceReferenceSchema.shape.context,
|
|
73
|
+
status: CloudResourceReferenceSchema.shape.status,
|
|
72
74
|
preview: z.string().optional(),
|
|
73
75
|
icon: z.string().optional(),
|
|
74
76
|
priority: z.number().int().min(0).max(9).optional(),
|
package/src/api/search.ts
CHANGED
|
@@ -139,9 +139,14 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
|
|
|
139
139
|
const stream = acceptsStream(c.req.header("accept"));
|
|
140
140
|
const requestId = normalizeInvocationRequestId(c.req.header("x-request-id"));
|
|
141
141
|
const invocationAuthority = invocationAuthorityFromRequest(auth.getAuthority(c));
|
|
142
|
+
// One request-wide budget covers resource access, signing and provider I/O.
|
|
143
|
+
const providerDeadline = AbortSignal.timeout(PROVIDER_TIMEOUT_MS);
|
|
144
|
+
// A client that stops reading a stream ends the search too.
|
|
145
|
+
const streamCancelled = new AbortController();
|
|
146
|
+
const fanoutSignal = AbortSignal.any([c.req.raw.signal, providerDeadline, streamCancelled.signal]);
|
|
142
147
|
let entries: CapabilityRegistryEntry[];
|
|
143
148
|
try {
|
|
144
|
-
entries = await discoverableAppEntries(appAccessAuthority(c), await registry(), dependencies.listApps);
|
|
149
|
+
entries = await discoverableAppEntries(appAccessAuthority(c), await registry(), dependencies.listApps, { signal: fanoutSignal });
|
|
145
150
|
} catch (error) {
|
|
146
151
|
log.warn("Search capability registry unavailable", { error: error instanceof Error ? error.message : String(error) });
|
|
147
152
|
return c.json(
|
|
@@ -227,11 +232,6 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
|
|
|
227
232
|
// at GLOBAL_RESULT_LIMIT so a single app can saturate the response
|
|
228
233
|
// but no more.
|
|
229
234
|
const effectiveProviderLimit = active.length === 1 ? Math.min(GLOBAL_RESULT_LIMIT, query.provider_limit * 3) : query.provider_limit;
|
|
230
|
-
// The same request-wide budget covers signing and provider I/O.
|
|
231
|
-
const providerDeadline = AbortSignal.timeout(PROVIDER_TIMEOUT_MS);
|
|
232
|
-
// A client that stops reading a stream, such as the browser on the next keystroke, ends the search too.
|
|
233
|
-
const streamCancelled = new AbortController();
|
|
234
|
-
const fanoutSignal = AbortSignal.any([c.req.raw.signal, providerDeadline, streamCancelled.signal]);
|
|
235
235
|
// Guard the whole issuance batch once, then reuse the prepared signer for
|
|
236
236
|
// every target. This is one Postgres check per outer search request, not
|
|
237
237
|
// one check per provider.
|
|
@@ -258,7 +258,7 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
|
|
|
258
258
|
signer,
|
|
259
259
|
issuer: signer.issuer,
|
|
260
260
|
},
|
|
261
|
-
{ db, groupsAdmin: signer.groupsAdmin },
|
|
261
|
+
{ db, groupsAdmin: signer.groupsAdmin, signal: fanoutSignal },
|
|
262
262
|
),
|
|
263
263
|
fanoutSignal,
|
|
264
264
|
),
|
|
@@ -353,6 +353,8 @@ export const createSearchRoutes = (dependencies: SearchRouteDependencies = {}) =
|
|
|
353
353
|
ref: view.ref,
|
|
354
354
|
title: view.title,
|
|
355
355
|
href: open.href,
|
|
356
|
+
context: view.context,
|
|
357
|
+
status: view.status,
|
|
356
358
|
preview: view.preview,
|
|
357
359
|
icon: view.icon,
|
|
358
360
|
priority: view.priority,
|
package/src/api/widgets.ts
CHANGED
|
@@ -353,6 +353,7 @@ export const createWidgetRoutes = (dependencies: WidgetRouteDependencies = {}) =
|
|
|
353
353
|
})
|
|
354
354
|
.post(
|
|
355
355
|
"/widgets/v1",
|
|
356
|
+
auth.readOnly,
|
|
356
357
|
bodyLimit({ maxSize: WIDGET_REQUEST_MAX_BYTES, onError: (c) => c.json({ message: "Widget request is too large" }, 400) }),
|
|
357
358
|
async (c) => {
|
|
358
359
|
let raw: unknown;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { SQL, TransactionSQL } from "bun";
|
|
2
|
+
import { accountCategory } from "../contracts/account-categories";
|
|
3
|
+
import { decodeAccountCategoryEnabled } from "../services/account-category-policy";
|
|
4
|
+
import { type AppPwaWebSignIn, pwaFail } from "./contracts";
|
|
5
|
+
|
|
6
|
+
type User = {
|
|
7
|
+
auth_epoch: string;
|
|
8
|
+
provider: "local" | "ipa";
|
|
9
|
+
profile: "guest" | "user";
|
|
10
|
+
account_expires: Date | null;
|
|
11
|
+
};
|
|
12
|
+
type Family = {
|
|
13
|
+
auth_epoch: string;
|
|
14
|
+
expires_at: Date;
|
|
15
|
+
revoked_at: Date | null;
|
|
16
|
+
pwa_device_id: string | null;
|
|
17
|
+
signing_kid: string;
|
|
18
|
+
legal_pending: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const databaseClock = async (tx: TransactionSQL): Promise<Date> => {
|
|
22
|
+
const [row] = await tx<{ time: Date }[]>`SELECT clock_timestamp() AS time`;
|
|
23
|
+
if (!row) return pwaFail("UNAVAILABLE", 503);
|
|
24
|
+
return row.time;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Supported on PostgreSQL 15–17. Keep the query cancellable through execution. */
|
|
28
|
+
export const boundTransaction = (tx: SQL, remainingMilliseconds: number) => {
|
|
29
|
+
if (!Number.isSafeInteger(remainingMilliseconds) || remainingMilliseconds < 1) return pwaFail("UNAVAILABLE", 503);
|
|
30
|
+
const timeout = `${remainingMilliseconds}ms`;
|
|
31
|
+
return tx`SELECT set_config('statement_timeout', ${timeout}, true), set_config('lock_timeout', ${timeout}, true),
|
|
32
|
+
set_config('idle_in_transaction_session_timeout', ${timeout}, true)`;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Core order: user -> session family -> signing key. All locks survive domain writes and commit.
|
|
37
|
+
* Logout updates this family; epoch invalidation/deactivation updates this user; key revoke updates this key.
|
|
38
|
+
* Never replace this with a cached authentication callback or a preflight HTTP request.
|
|
39
|
+
*/
|
|
40
|
+
export const lockWebAuthority = async (tx: TransactionSQL, signIn: AppPwaWebSignIn) => {
|
|
41
|
+
const [user] = await tx<User[]>`SELECT auth_epoch, provider, profile, account_expires
|
|
42
|
+
FROM auth.users WHERE id = ${signIn.userId}::uuid FOR NO KEY UPDATE`;
|
|
43
|
+
if (!user) return pwaFail("WEB_SESSION_REQUIRED", 401);
|
|
44
|
+
const [family] = await tx<Family[]>`SELECT auth_epoch, expires_at, revoked_at, pwa_device_id, signing_kid, legal_pending
|
|
45
|
+
FROM auth.session_families WHERE sid = ${signIn.sid}::uuid AND user_id = ${signIn.userId}::uuid FOR UPDATE`;
|
|
46
|
+
if (!family) return pwaFail("WEB_SESSION_REQUIRED", 401);
|
|
47
|
+
const [key] = await tx<{ state: string }[]>`SELECT state FROM auth.signing_keys
|
|
48
|
+
WHERE kid = ${family.signing_kid} FOR SHARE`;
|
|
49
|
+
const categoryKey = `user.category.${accountCategory(user)}.enabled`;
|
|
50
|
+
// A row lock cannot serialize an absent default with the first policy INSERT.
|
|
51
|
+
// Settings has no shared category writer lock; ordinary DML conflicts with SHARE.
|
|
52
|
+
await tx`LOCK TABLE settings.entries IN SHARE MODE`;
|
|
53
|
+
const [category] = await tx<{ value: string }[]>`SELECT value FROM settings.entries WHERE key = ${categoryKey} FOR SHARE`;
|
|
54
|
+
const legal =
|
|
55
|
+
!family.legal_pending || (await tx`SELECT 1 FROM auth.legal_acceptances WHERE user_id = ${signIn.userId}::uuid LIMIT 1`).length > 0;
|
|
56
|
+
const categoryEnabled = await decodeAccountCategoryEnabled(category?.value);
|
|
57
|
+
const check = (time: Date) => {
|
|
58
|
+
if (
|
|
59
|
+
family.revoked_at ||
|
|
60
|
+
family.pwa_device_id ||
|
|
61
|
+
!key ||
|
|
62
|
+
key.state === "revoked" ||
|
|
63
|
+
String(family.auth_epoch) !== String(user.auth_epoch) ||
|
|
64
|
+
family.expires_at <= time ||
|
|
65
|
+
!legal ||
|
|
66
|
+
(user.account_expires && user.account_expires <= time) ||
|
|
67
|
+
!categoryEnabled
|
|
68
|
+
) {
|
|
69
|
+
return pwaFail("WEB_SESSION_REQUIRED", 401);
|
|
70
|
+
}
|
|
71
|
+
return new Date(Math.min(family.expires_at.getTime(), user.account_expires?.getTime() ?? Infinity));
|
|
72
|
+
};
|
|
73
|
+
await check(await databaseClock(tx));
|
|
74
|
+
return { user, family, check };
|
|
75
|
+
};
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import type { SQL, TransactionSQL } from "bun";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { AppInstallable } from "../contracts/installable";
|
|
4
|
+
import type { PermissionLevel } from "../server/services/access";
|
|
5
|
+
|
|
6
|
+
export const APP_PWA_LIMITS = {
|
|
7
|
+
qrSeconds: 120,
|
|
8
|
+
deviceSeconds: 12 * 60 * 60,
|
|
9
|
+
visitIdleSeconds: 15 * 60,
|
|
10
|
+
visitSeconds: 60 * 60,
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export const AppPwaId = z.uuid();
|
|
14
|
+
export const AppPwaSecret = z.string().regex(/^[A-Za-z0-9_-]{43}$/);
|
|
15
|
+
export const AppPwaClaim = z.strictObject({ qr: z.string() });
|
|
16
|
+
export const AppPwaAttempt = z.strictObject({ attemptId: AppPwaId, input: z.unknown() });
|
|
17
|
+
|
|
18
|
+
export type AppPwaDeviceActor = {
|
|
19
|
+
kind: "device";
|
|
20
|
+
appId: string;
|
|
21
|
+
deviceId: string;
|
|
22
|
+
ownerUserId: string;
|
|
23
|
+
};
|
|
24
|
+
export type AppPwaVisitActor = Omit<AppPwaDeviceActor, "kind"> & {
|
|
25
|
+
kind: "visit";
|
|
26
|
+
visitId: string;
|
|
27
|
+
/** Validated against the application's subject schema. Domain interpretation belongs to the app. */
|
|
28
|
+
subject: unknown;
|
|
29
|
+
};
|
|
30
|
+
export type AppPwaActor = AppPwaDeviceActor | AppPwaVisitActor;
|
|
31
|
+
export type AppPwaDeviceView = {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
state: "pending" | "active" | "expired" | "revoked";
|
|
35
|
+
expiresAt: string | null;
|
|
36
|
+
};
|
|
37
|
+
export type AppPwaVisitView = {
|
|
38
|
+
id: string;
|
|
39
|
+
state: "pending" | "active" | "expired" | "ended" | "revoked";
|
|
40
|
+
expiresAt: string | null;
|
|
41
|
+
idleExpiresAt: string | null;
|
|
42
|
+
};
|
|
43
|
+
export type AppPwaWebSignIn = { userId: string; sid: string };
|
|
44
|
+
export type AppPwaStaffAction = "issue-device" | "list-devices" | "device-status" | "revoke-device" | "issue-visit" | "revoke-visit";
|
|
45
|
+
|
|
46
|
+
export type AppPwaVisitContext = {
|
|
47
|
+
actor: AppPwaVisitActor;
|
|
48
|
+
tx: TransactionSQL;
|
|
49
|
+
signal: AbortSignal;
|
|
50
|
+
};
|
|
51
|
+
export type AppPwaCommandContext = AppPwaVisitContext & {
|
|
52
|
+
/** Complete the visit atomically with the domain write and its attempt result. */
|
|
53
|
+
endVisit(): void;
|
|
54
|
+
};
|
|
55
|
+
export type AppPwaDeviceContext = {
|
|
56
|
+
actor: AppPwaDeviceActor;
|
|
57
|
+
tx: TransactionSQL;
|
|
58
|
+
signal: AbortSignal;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type AppPwaQuery = {
|
|
62
|
+
run(context: AppPwaVisitContext, input: unknown): Promise<unknown>;
|
|
63
|
+
};
|
|
64
|
+
export type AppPwaCommand = {
|
|
65
|
+
parse(input: unknown): unknown;
|
|
66
|
+
run(context: AppPwaCommandContext, input: unknown): Promise<unknown>;
|
|
67
|
+
};
|
|
68
|
+
export type AppPwaDeviceOperation = {
|
|
69
|
+
permission: Exclude<PermissionLevel, "none">;
|
|
70
|
+
run(context: AppPwaDeviceContext, input: unknown): Promise<unknown>;
|
|
71
|
+
/** Lock the app-owned resource/junction rows that determine these grant IDs. Called again before commit. */
|
|
72
|
+
accessIds(context: AppPwaDeviceContext, input: unknown): Promise<string[]>;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const parse = <T>(schema: z.ZodType<T>, value: unknown): T => {
|
|
76
|
+
const result = schema.safeParse(value);
|
|
77
|
+
if (!result.success) throw new AppPwaError("INVALID_INPUT", 400);
|
|
78
|
+
return result.data;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** A query never renews a visit. Use the supplied transaction even for domain reads. */
|
|
82
|
+
export const appPwaQuery = <I, O>(definition: {
|
|
83
|
+
input: z.ZodType<I>;
|
|
84
|
+
output: z.ZodType<O>;
|
|
85
|
+
handler(context: AppPwaVisitContext, input: I): Promise<O>;
|
|
86
|
+
}): AppPwaQuery => ({
|
|
87
|
+
run: async (context, input) => parse(definition.output, await definition.handler(context, parse(definition.input, input))),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/** Only a newly committed successful command renews idle time. Live retries return the stored result; terminal retries return status only. */
|
|
91
|
+
export const appPwaCommand = <I, O>(definition: {
|
|
92
|
+
input: z.ZodType<I>;
|
|
93
|
+
output: z.ZodType<O>;
|
|
94
|
+
handler(context: AppPwaCommandContext, input: I): Promise<O>;
|
|
95
|
+
}): AppPwaCommand => ({
|
|
96
|
+
parse: (input) => parse(definition.input, input),
|
|
97
|
+
run: async (context, input) => parse(definition.output, await definition.handler(context, parse(definition.input, input))),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A device-only local service adapter. It receives device provenance, never owner credentials.
|
|
102
|
+
* The platform intersects its explicit ceiling with current resource grants before calling it.
|
|
103
|
+
* Cross-app credential exchange is deliberately outside this adapter's contract.
|
|
104
|
+
*/
|
|
105
|
+
export const appPwaDeviceOperation = <I, O>(definition: {
|
|
106
|
+
input: z.ZodType<I>;
|
|
107
|
+
output: z.ZodType<O>;
|
|
108
|
+
permission: Exclude<PermissionLevel, "none">;
|
|
109
|
+
accessIds(context: AppPwaDeviceContext, input: I): Promise<string[]>;
|
|
110
|
+
handler(context: AppPwaDeviceContext, input: I): Promise<O>;
|
|
111
|
+
}): AppPwaDeviceOperation => ({
|
|
112
|
+
permission: definition.permission,
|
|
113
|
+
accessIds: (context, input) => definition.accessIds(context, parse(definition.input, input)),
|
|
114
|
+
run: async (context, input) => parse(definition.output, await definition.handler(context, parse(definition.input, input))),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
export type AppPwaCoreQuery = "address.autocomplete";
|
|
118
|
+
|
|
119
|
+
export type AppPwaOptions = {
|
|
120
|
+
app: { id: string; installable: Pick<AppInstallable, "path"> };
|
|
121
|
+
/** Authoritative configured public origin, never a Host/Forwarded/request header. HTTPS is mandatory. */
|
|
122
|
+
publicOrigin: string;
|
|
123
|
+
schema: string;
|
|
124
|
+
db?: SQL;
|
|
125
|
+
/** Positive safe integer seconds; capped at 12 hours, then bounded by browser and initiating family. */
|
|
126
|
+
deviceTtlSeconds?: number;
|
|
127
|
+
subject: z.ZodType;
|
|
128
|
+
/** Explicit application budgets, covering transport, stored results, and staff pagination. */
|
|
129
|
+
budgets: { requestBytes: number; responseBytes: number; listPageSize: number; maxAccessIds: number };
|
|
130
|
+
/** Mandatory application authorization, repeated under the current web authority locks for every staff action. */
|
|
131
|
+
authorizeStaff(action: AppPwaStaffAction, signIn: AppPwaWebSignIn, tx: TransactionSQL): Promise<boolean>;
|
|
132
|
+
/** Authorize exposing this validated domain subject to a customer; lock its authority using tx.
|
|
133
|
+
* Called before issuance and again before commit, including issuance retries. */
|
|
134
|
+
authorizeVisit(signIn: AppPwaWebSignIn, subject: unknown, tx: TransactionSQL): Promise<boolean>;
|
|
135
|
+
queries?: Readonly<Record<string, AppPwaQuery>>;
|
|
136
|
+
/** Read-only platform queries available through the closed guest Core-query route. Empty by default. */
|
|
137
|
+
coreQueries?: readonly AppPwaCoreQuery[];
|
|
138
|
+
commands?: Readonly<Record<string, AppPwaCommand>>;
|
|
139
|
+
deviceOperations?: Readonly<Record<string, AppPwaDeviceOperation>>;
|
|
140
|
+
/** Missing operation declarations deny access; operation registration alone grants nothing. */
|
|
141
|
+
devicePermissions?: Readonly<Record<string, Exclude<PermissionLevel, "none">>>;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export type AppPwaErrorCode =
|
|
145
|
+
| "INVALID_INPUT"
|
|
146
|
+
| "INVALID_QR"
|
|
147
|
+
| "ORIGIN_REQUIRED"
|
|
148
|
+
| "JSON_REQUIRED"
|
|
149
|
+
| "CSRF_REQUIRED"
|
|
150
|
+
| "BROWSER_REQUIRED"
|
|
151
|
+
| "DUPLICATE_COOKIE"
|
|
152
|
+
| "WEB_SESSION_REQUIRED"
|
|
153
|
+
| "FORBIDDEN"
|
|
154
|
+
| "NOT_FOUND"
|
|
155
|
+
| "EXPIRED"
|
|
156
|
+
| "REVOKED"
|
|
157
|
+
| "ALREADY_CLAIMED"
|
|
158
|
+
| "ACTIVE_VISIT"
|
|
159
|
+
| "ATTEMPT_CONFLICT"
|
|
160
|
+
| "TOO_LARGE"
|
|
161
|
+
| "RATE_LIMITED"
|
|
162
|
+
| "UNAVAILABLE"
|
|
163
|
+
| `APP_${string}`;
|
|
164
|
+
|
|
165
|
+
export class AppPwaError extends Error {
|
|
166
|
+
constructor(
|
|
167
|
+
readonly code: AppPwaErrorCode,
|
|
168
|
+
readonly status: 400 | 401 | 403 | 404 | 409 | 410 | 413 | 415 | 429 | 503,
|
|
169
|
+
) {
|
|
170
|
+
super(code);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const pwaFail = (code: AppPwaErrorCode, status: AppPwaError["status"]): never => {
|
|
175
|
+
throw new AppPwaError(code, status);
|
|
176
|
+
};
|
|
177
|
+
export const parseAppPwaInput = parse;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { GeoError } from "../services/geo/errors";
|
|
2
|
+
|
|
3
|
+
/** Guest-safe errors from the allowlisted platform adapter, never from application handlers. */
|
|
4
|
+
export class AppPwaCoreQueryError extends Error {
|
|
5
|
+
readonly code: GeoError["code"];
|
|
6
|
+
readonly status: GeoError["status"];
|
|
7
|
+
readonly details?: { scope: "person" | "cloud"; resetsAt: string };
|
|
8
|
+
constructor(error: GeoError) {
|
|
9
|
+
super(error.code);
|
|
10
|
+
this.code = error.code;
|
|
11
|
+
this.status = error.status;
|
|
12
|
+
if (
|
|
13
|
+
error.code === "GEO_BUDGET_EXHAUSTED" &&
|
|
14
|
+
(error.details.scope === "person" || error.details.scope === "cloud") &&
|
|
15
|
+
typeof error.details.resetsAt === "string"
|
|
16
|
+
) {
|
|
17
|
+
this.details = { scope: error.details.scope, resetsAt: error.details.resetsAt };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export {
|
|
2
|
+
APP_PWA_LIMITS,
|
|
3
|
+
type AppPwaActor,
|
|
4
|
+
type AppPwaCommandContext,
|
|
5
|
+
type AppPwaCoreQuery,
|
|
6
|
+
type AppPwaDeviceActor,
|
|
7
|
+
type AppPwaDeviceContext,
|
|
8
|
+
type AppPwaDeviceView,
|
|
9
|
+
AppPwaError,
|
|
10
|
+
type AppPwaErrorCode,
|
|
11
|
+
type AppPwaOptions,
|
|
12
|
+
type AppPwaStaffAction,
|
|
13
|
+
type AppPwaVisitActor,
|
|
14
|
+
type AppPwaVisitContext,
|
|
15
|
+
type AppPwaVisitView,
|
|
16
|
+
type AppPwaWebSignIn,
|
|
17
|
+
appPwaCommand,
|
|
18
|
+
appPwaDeviceOperation,
|
|
19
|
+
appPwaQuery,
|
|
20
|
+
} from "./contracts";
|
|
21
|
+
export { setupAppPwa } from "./schema";
|
|
22
|
+
export { type AppPwaService, createAppPwaService } from "./service";
|
|
23
|
+
export { createAppPwa, createAppPwaRouter } from "./transport";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Browser-safe QR protocol. Parsing never navigates or fetches the scanned URL. */
|
|
2
|
+
export type AppPwaQr = { kind: "device" | "visit"; secret: string };
|
|
3
|
+
const SECRET = /^[A-Za-z0-9_-]{43}$/;
|
|
4
|
+
|
|
5
|
+
export const appPwaQr = (origin: string, path: string, value: AppPwaQr): string => {
|
|
6
|
+
if (!SECRET.test(value.secret)) throw new Error("Invalid App-PWA secret");
|
|
7
|
+
return `${origin}${path}#cloud-app-pwa:${value.kind}:${value.secret}`;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Exact serialized comparison rejects normalization, escapes, alternate ports, userinfo, query, and whitespace. */
|
|
11
|
+
export const parseAppPwaQr = (text: string, origin: string, path: string): AppPwaQr | null => {
|
|
12
|
+
const prefix = `${origin}${path}#cloud-app-pwa:`;
|
|
13
|
+
if (text.length !== prefix.length + 7 + 43 && text.length !== prefix.length + 6 + 43) return null;
|
|
14
|
+
if (!text.startsWith(prefix)) return null;
|
|
15
|
+
const value = text.slice(prefix.length);
|
|
16
|
+
const kind = value.startsWith("device:") ? "device" : value.startsWith("visit:") ? "visit" : null;
|
|
17
|
+
if (!kind) return null;
|
|
18
|
+
const secret = value.slice(kind.length + 1);
|
|
19
|
+
return SECRET.test(secret) && text === appPwaQr(origin, path, { kind, secret }) ? { kind, secret } : null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Drop a directly opened fragment before asynchronous work; the supported input remains the scanner. */
|
|
23
|
+
export const clearAppPwaFragment = (): void => {
|
|
24
|
+
if (typeof window !== "undefined" && window.location.hash) {
|
|
25
|
+
window.history.replaceState(window.history.state, "", window.location.pathname + window.location.search);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import { ensureSchema } from "../services/postgres";
|
|
3
|
+
import { withAppPwaTransaction } from "./transaction";
|
|
4
|
+
|
|
5
|
+
export const appPwaSchemaName = (name: string): string => {
|
|
6
|
+
if (!/^[a-z_][a-z0-9_]{0,62}$/.test(name)) throw new Error("App-PWA schema must be a plain lowercase Postgres identifier");
|
|
7
|
+
return name;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** Must finish successfully in application setup before listening. No best-effort migration or memory authority. */
|
|
11
|
+
export const setupAppPwa = async (db: SQL, schemaName: string): Promise<void> => {
|
|
12
|
+
const schema = appPwaSchemaName(schemaName);
|
|
13
|
+
await withAppPwaTransaction(db, undefined, async (tx) => {
|
|
14
|
+
await tx`SELECT pg_advisory_xact_lock(hashtextextended(${`cloud:app-pwa:${schema}`}, 0))`;
|
|
15
|
+
await ensureSchema(tx, schema);
|
|
16
|
+
await tx
|
|
17
|
+
.unsafe(`CREATE TABLE IF NOT EXISTS ${schema}._cloud_pwa_browsers (
|
|
18
|
+
app_id text NOT NULL, credential_hash text NOT NULL, expires_at timestamptz NOT NULL,
|
|
19
|
+
device_id uuid, PRIMARY KEY (app_id, credential_hash))`)
|
|
20
|
+
.simple();
|
|
21
|
+
await tx
|
|
22
|
+
.unsafe(`CREATE TABLE IF NOT EXISTS ${schema}._cloud_pwa_devices (
|
|
23
|
+
app_id text NOT NULL, id uuid NOT NULL, request_id uuid NOT NULL, input_digest text NOT NULL,
|
|
24
|
+
owner_user_id uuid NOT NULL, initiator_sid uuid NOT NULL, auth_epoch bigint NOT NULL,
|
|
25
|
+
name text NOT NULL, secret_hash text NOT NULL, browser_hash text,
|
|
26
|
+
state text NOT NULL CHECK (state IN ('pending', 'active', 'revoked')),
|
|
27
|
+
issued_at timestamptz NOT NULL, qr_expires_at timestamptz NOT NULL,
|
|
28
|
+
activated_at timestamptz, expires_at timestamptz, active_visit_id uuid,
|
|
29
|
+
PRIMARY KEY (app_id, id), UNIQUE (app_id, owner_user_id, initiator_sid, request_id))`)
|
|
30
|
+
.simple();
|
|
31
|
+
await tx
|
|
32
|
+
.unsafe(`CREATE UNIQUE INDEX IF NOT EXISTS _cloud_pwa_device_secret ON ${schema}._cloud_pwa_devices(app_id, secret_hash)`)
|
|
33
|
+
.simple();
|
|
34
|
+
await tx
|
|
35
|
+
.unsafe(`CREATE INDEX IF NOT EXISTS _cloud_pwa_device_owner ON ${schema}._cloud_pwa_devices(app_id, owner_user_id, id)`)
|
|
36
|
+
.simple();
|
|
37
|
+
await tx
|
|
38
|
+
.unsafe(`CREATE TABLE IF NOT EXISTS ${schema}._cloud_pwa_visits (
|
|
39
|
+
app_id text NOT NULL, id uuid NOT NULL, device_id uuid NOT NULL,
|
|
40
|
+
request_id uuid NOT NULL, input_digest text NOT NULL, secret_hash text NOT NULL,
|
|
41
|
+
subject jsonb NOT NULL, browser_hash text,
|
|
42
|
+
state text NOT NULL CHECK (state IN ('pending', 'active', 'ended', 'revoked')),
|
|
43
|
+
issued_at timestamptz NOT NULL, qr_expires_at timestamptz NOT NULL,
|
|
44
|
+
activated_at timestamptz, last_used_at timestamptz, expires_at timestamptz,
|
|
45
|
+
PRIMARY KEY (app_id, id), UNIQUE (app_id, device_id, request_id),
|
|
46
|
+
FOREIGN KEY (app_id, device_id) REFERENCES ${schema}._cloud_pwa_devices(app_id, id))`)
|
|
47
|
+
.simple();
|
|
48
|
+
await tx
|
|
49
|
+
.unsafe(`CREATE UNIQUE INDEX IF NOT EXISTS _cloud_pwa_visit_secret ON ${schema}._cloud_pwa_visits(app_id, secret_hash)`)
|
|
50
|
+
.simple();
|
|
51
|
+
await tx
|
|
52
|
+
.unsafe(`CREATE TABLE IF NOT EXISTS ${schema}._cloud_pwa_attempts (
|
|
53
|
+
app_id text NOT NULL, visit_id uuid NOT NULL, attempt_id uuid NOT NULL,
|
|
54
|
+
route text NOT NULL, input_digest text NOT NULL, result jsonb NOT NULL,
|
|
55
|
+
PRIMARY KEY (app_id, visit_id, attempt_id),
|
|
56
|
+
FOREIGN KEY (app_id, visit_id) REFERENCES ${schema}._cloud_pwa_visits(app_id, id))`)
|
|
57
|
+
.simple();
|
|
58
|
+
});
|
|
59
|
+
};
|