@k2b/cloud 0.43.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 +7 -2
- 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 +158 -12
- package/src/_internal/capability-origin.ts +4 -0
- package/src/_internal/capability-streams.ts +7 -5
- package/src/_internal/define-app.ts +57 -6
- 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/process-identity.ts +7 -2
- package/src/_internal/registry-validation.ts +11 -1
- package/src/_internal/registry.ts +8 -1
- package/src/_internal/runtime-context.ts +8 -1
- package/src/access/AppAccessEditor.tsx +2 -0
- package/src/access/PermissionEditor.tsx +9 -3
- package/src/access/PrincipalPicker.tsx +14 -4
- package/src/access/ResourceApiKeys.tsx +3 -4
- package/src/access/app-access-messages.ts +6 -0
- package/src/account/ProfilePictureDialog.tsx +699 -0
- package/src/account/profile-picture-messages.ts +269 -0
- package/src/account/ui.ts +7 -0
- 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/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/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 +31 -22
- package/src/ai/http.ts +17 -29
- package/src/ai/index.ts +11 -5
- package/src/ai/inference-calls.ts +5 -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.ts +26 -9
- 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 +55 -6
- package/src/ai/provider-fetch.ts +4 -0
- package/src/ai/provider.ts +1 -1
- package/src/ai/quota-limits.ts +36 -0
- package/src/ai/quota-provider.ts +4 -2
- package/src/ai/quota-report.ts +7 -4
- 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 +21 -44
- 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 +27 -22
- 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 +16 -0
- 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/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 +28 -4
- package/src/api/index.ts +7 -1
- package/src/api/mcp.ts +175 -2
- package/src/api/me.ts +6 -4
- package/src/api/pwa-phone.ts +75 -26
- 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/app-pwa/contracts.ts +4 -0
- package/src/app-pwa/core-query-error.ts +20 -0
- package/src/app-pwa/index.ts +1 -0
- package/src/app-pwa/service.ts +91 -0
- package/src/app-pwa/transport.ts +22 -0
- package/src/approvals/messages.ts +2 -2
- package/src/browser/cloud-id.ts +86 -0
- package/src/browser/resource-clipboard.ts +4 -0
- package/src/browser/resource-links.ts +52 -0
- package/src/capabilities/server-invoke.ts +2 -11
- package/src/capabilities/testing.ts +5 -2
- package/src/cli/access.ts +6 -5
- 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 +5 -2
- package/src/cli/apps.ts +89 -26
- package/src/cli/capabilities.ts +22 -1
- package/src/cli/events.ts +320 -0
- package/src/config/env.ts +40 -0
- package/src/contracts/app-access.ts +26 -2
- package/src/contracts/app-details.ts +89 -0
- package/src/contracts/app-events.ts +51 -0
- package/src/contracts/app.ts +21 -5
- package/src/contracts/capabilities.ts +143 -3
- package/src/contracts/index.ts +2 -0
- package/src/contracts/notification-types.ts +36 -9
- package/src/contracts/pwa.ts +17 -1
- package/src/contracts/registry.ts +6 -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 +1 -1
- package/src/server/api/respond.ts +8 -3
- package/src/server/index.ts +1 -0
- package/src/server/middleware/app-access.ts +1 -1
- package/src/server/middleware/auth.ts +21 -7
- package/src/server/middleware/index.ts +1 -0
- package/src/server/middleware/invocation.ts +2 -0
- package/src/server/public-storage.ts +106 -0
- package/src/server/services/access.ts +8 -0
- package/src/services/accounts/app.ts +6 -0
- package/src/services/accounts/entities.ts +14 -3
- package/src/services/accounts/groups.ts +3 -1
- package/src/services/accounts/identities.ts +62 -10
- package/src/services/accounts/users.ts +7 -6
- package/src/services/app-access.ts +25 -7
- 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/ledger.ts +28 -10
- package/src/services/geo/service.ts +43 -24
- package/src/services/identity/core-origin.ts +12 -0
- package/src/services/identity/invocation-actor.ts +20 -2
- package/src/services/identity/invocation-authority.ts +4 -0
- package/src/services/identity/invocation-token.ts +238 -56
- package/src/services/identity/issue-invocation.ts +6 -1
- package/src/services/image-metadata.ts +65 -1
- package/src/services/index.ts +13 -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/session/index.ts +14 -0
- package/src/services/settings/core-settings.ts +71 -43
- 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 +9 -3
- package/src/ssr/AdminSidebar.tsx +7 -7
- package/src/ssr/AppAccessDenied.tsx +2 -0
- package/src/ssr/AppBadge.tsx +14 -0
- package/src/ssr/AppLaunchpadPanel.tsx +1 -1
- package/src/ssr/CloudShell.island.tsx +25 -4
- package/src/ssr/Layout.tsx +8 -6
- package/src/ssr/PwaLayout.tsx +16 -1
- package/src/ssr/PwaPartCount.island.tsx +23 -0
- package/src/ssr/SettingsSaveOutcome.tsx +58 -0
- package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
- package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
- package/src/ssr/admin-active-link.ts +16 -0
- package/src/ssr/admin-navigation.ts +25 -20
- package/src/ssr/app-badges.ts +31 -7
- package/src/ssr/index.ts +8 -0
- package/src/ssr/page-trail-runtime.ts +3 -1
- package/src/ssr/platform-messages.ts +9 -1
- package/src/ssr/pwa-messages.ts +2 -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
package/src/api/pwa.ts
CHANGED
|
@@ -28,7 +28,7 @@ const log = logger("pwa");
|
|
|
28
28
|
|
|
29
29
|
const MESSAGES: Record<PwaError["code"], string> = {
|
|
30
30
|
UNAVAILABLE: "The mobile app is not available right now.",
|
|
31
|
-
REAUTHENTICATE: "Sign in again to pair a
|
|
31
|
+
REAUTHENTICATE: "Sign in again to pair a device.",
|
|
32
32
|
FORBIDDEN: "Use Cloud on the web for this.",
|
|
33
33
|
ACCOUNT_BLOCKED: "This account cannot use the mobile app right now.",
|
|
34
34
|
INVALID_REQUEST: "The request is not valid.",
|
|
@@ -40,7 +40,7 @@ const MESSAGES: Record<PwaError["code"], string> = {
|
|
|
40
40
|
ALREADY_PAIRED: "This app is paired with another account.",
|
|
41
41
|
ACCOUNT_MISMATCH: "This browser is signed in to another account.",
|
|
42
42
|
LIMIT_REACHED: "The limit for this account was reached.",
|
|
43
|
-
UNPAIRED: "This
|
|
43
|
+
UNPAIRED: "This device is not paired.",
|
|
44
44
|
};
|
|
45
45
|
|
|
46
46
|
/** `{ code, message }` with a safe English message; the UI maps codes to its own catalog. */
|
|
@@ -95,7 +95,7 @@ const webActor = async (c: Context) => {
|
|
|
95
95
|
const session = token ? await auth.session.authenticateRequest(c, token) : null;
|
|
96
96
|
if (!session) throw new PwaError("REAUTHENTICATE", 403);
|
|
97
97
|
if (session.data.kind !== "web") throw new PwaError("FORBIDDEN", 403);
|
|
98
|
-
return { userId: session.user.id, sid: session.data.sid };
|
|
98
|
+
return { userId: session.user.id, sid: session.data.sid, admin: session.user.roles.includes("admin") };
|
|
99
99
|
};
|
|
100
100
|
|
|
101
101
|
/** The phone this request also comes from (Chrome on a paired Android phone shares the app's cookies). */
|
|
@@ -119,7 +119,7 @@ export const createPwaRoutes = (options: PwaRouteOptions = {}) => {
|
|
|
119
119
|
"/pairings",
|
|
120
120
|
describeRoute({
|
|
121
121
|
tags,
|
|
122
|
-
summary: "Start pairing a
|
|
122
|
+
summary: "Start pairing a device",
|
|
123
123
|
description: "Needs a web sign-in from the last ten minutes. The link secret is returned once.",
|
|
124
124
|
responses: { 201: jsonResponse(PwaPairingStartResultSchema, "Pairing started") },
|
|
125
125
|
}),
|
|
@@ -129,13 +129,33 @@ export const createPwaRoutes = (options: PwaRouteOptions = {}) => {
|
|
|
129
129
|
return c.json(await service.startPairing(actor), 201);
|
|
130
130
|
},
|
|
131
131
|
)
|
|
132
|
+
.post(
|
|
133
|
+
"/users/:userId/pairings",
|
|
134
|
+
describeRoute({
|
|
135
|
+
tags,
|
|
136
|
+
summary: "Pair the Cloud app for another person",
|
|
137
|
+
description:
|
|
138
|
+
"Administrator web session only, signed in within ten minutes. App sessions and API keys are refused. The target account must be active. Uses the existing account pairing and device limits. After the administrator confirms the device code, the device must explicitly accept before credentials are issued.",
|
|
139
|
+
responses: {
|
|
140
|
+
201: jsonResponse(PwaPairingStartResultSchema, "Administrative pairing started"),
|
|
141
|
+
403: { description: "Administrator, fresh web sign-in or active account required" },
|
|
142
|
+
},
|
|
143
|
+
}),
|
|
144
|
+
v("param", z.object({ userId: z.string().uuid() }), pwaInvalidRequest),
|
|
145
|
+
async (c) => {
|
|
146
|
+
const actor = await webActor(c);
|
|
147
|
+
if (!actor.admin) throw new PwaError("FORBIDDEN", 403);
|
|
148
|
+
if (!shellAvailable(c)) throw new PwaError("UNAVAILABLE", 503);
|
|
149
|
+
return c.json(await service.startUserPairing(actor, c.req.valid("param").userId), 201);
|
|
150
|
+
},
|
|
151
|
+
)
|
|
132
152
|
.get(
|
|
133
153
|
"/pairings/:id",
|
|
134
154
|
describeRoute({
|
|
135
155
|
tags,
|
|
136
156
|
summary: "Read a pairing",
|
|
137
157
|
description:
|
|
138
|
-
"Only for the web session that started it. Never returns the code. A pending read is held for up to one poll interval and answers as soon as the
|
|
158
|
+
"Only for the web session that started it. Never returns the code. A pending read is held for up to one poll interval and answers as soon as the device claims the link.",
|
|
139
159
|
responses: { 200: jsonResponse(PwaPairingStatusSchema, "Pairing state") },
|
|
140
160
|
}),
|
|
141
161
|
v("param", IdParamSchema, pwaInvalidRequest),
|
|
@@ -147,7 +167,13 @@ export const createPwaRoutes = (options: PwaRouteOptions = {}) => {
|
|
|
147
167
|
)
|
|
148
168
|
.post(
|
|
149
169
|
"/pairings/:id/confirm",
|
|
150
|
-
describeRoute({
|
|
170
|
+
describeRoute({
|
|
171
|
+
tags,
|
|
172
|
+
summary: "Confirm a pairing with the code the device shows",
|
|
173
|
+
description:
|
|
174
|
+
"Administrative pairings enter awaiting_device; only the claiming device can then accept or reject. Self-pairing proceeds as before.",
|
|
175
|
+
responses: { 204: { description: "Confirmed" } },
|
|
176
|
+
}),
|
|
151
177
|
v("param", IdParamSchema, pwaInvalidRequest),
|
|
152
178
|
v("json", PwaPairingConfirmSchema, pwaInvalidRequest),
|
|
153
179
|
async (c) => {
|
|
@@ -166,7 +192,7 @@ export const createPwaRoutes = (options: PwaRouteOptions = {}) => {
|
|
|
166
192
|
)
|
|
167
193
|
.get(
|
|
168
194
|
"/devices",
|
|
169
|
-
describeRoute({ tags, summary: "List paired
|
|
195
|
+
describeRoute({ tags, summary: "List paired devices", responses: { 200: jsonResponse(PwaDeviceListSchema, "Active devices") } }),
|
|
170
196
|
async (c) => {
|
|
171
197
|
const actor = await webActor(c);
|
|
172
198
|
return c.json({ items: await service.list(actor, await currentDeviceId(c, actor.userId)) });
|
|
@@ -174,7 +200,7 @@ export const createPwaRoutes = (options: PwaRouteOptions = {}) => {
|
|
|
174
200
|
)
|
|
175
201
|
.delete(
|
|
176
202
|
"/devices/:id",
|
|
177
|
-
describeRoute({ tags, summary: "Remove a paired
|
|
203
|
+
describeRoute({ tags, summary: "Remove a paired device", responses: { 204: { description: "Removed; idempotent" } } }),
|
|
178
204
|
v("param", IdParamSchema, pwaInvalidRequest),
|
|
179
205
|
async (c) => {
|
|
180
206
|
await service.revoke(await webActor(c), c.req.valid("param").id);
|
|
@@ -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/app-pwa/contracts.ts
CHANGED
|
@@ -114,6 +114,8 @@ export const appPwaDeviceOperation = <I, O>(definition: {
|
|
|
114
114
|
run: async (context, input) => parse(definition.output, await definition.handler(context, parse(definition.input, input))),
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
export type AppPwaCoreQuery = "address.autocomplete";
|
|
118
|
+
|
|
117
119
|
export type AppPwaOptions = {
|
|
118
120
|
app: { id: string; installable: Pick<AppInstallable, "path"> };
|
|
119
121
|
/** Authoritative configured public origin, never a Host/Forwarded/request header. HTTPS is mandatory. */
|
|
@@ -131,6 +133,8 @@ export type AppPwaOptions = {
|
|
|
131
133
|
* Called before issuance and again before commit, including issuance retries. */
|
|
132
134
|
authorizeVisit(signIn: AppPwaWebSignIn, subject: unknown, tx: TransactionSQL): Promise<boolean>;
|
|
133
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[];
|
|
134
138
|
commands?: Readonly<Record<string, AppPwaCommand>>;
|
|
135
139
|
deviceOperations?: Readonly<Record<string, AppPwaDeviceOperation>>;
|
|
136
140
|
/** Missing operation declarations deny access; operation registration alone grants nothing. */
|
|
@@ -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
|
+
}
|
package/src/app-pwa/index.ts
CHANGED
package/src/app-pwa/service.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
import { sql, type TransactionSQL } from "bun";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
+
import { GeoAutocompleteInputSchema } from "../contracts/geo";
|
|
3
4
|
import { isInstallablePath } from "../contracts/installable";
|
|
4
5
|
import { PWA_LIMITS } from "../contracts/pwa";
|
|
6
|
+
import { ratelimit } from "../server/ratelimit";
|
|
7
|
+
import { REQUEST_IDLE_TIMEOUT_SECONDS } from "../server/request-timeout";
|
|
5
8
|
import { getEffectivePermission, hasPermission } from "../server/services/access";
|
|
6
9
|
import { audit } from "../services/audit";
|
|
10
|
+
import { GeoError } from "../services/geo/errors";
|
|
11
|
+
import { coreGeo } from "../services/geo/service";
|
|
12
|
+
import { geoPlan, readGeoConfig } from "../services/geo/settings";
|
|
7
13
|
import { pairingSecret } from "../services/pairing-secret";
|
|
14
|
+
import { get as getSetting } from "../services/settings";
|
|
8
15
|
import { databaseClock, lockWebAuthority } from "./authority";
|
|
9
16
|
import {
|
|
10
17
|
type AppPwaActor,
|
|
@@ -22,9 +29,11 @@ import {
|
|
|
22
29
|
parseAppPwaInput as parse,
|
|
23
30
|
pwaFail,
|
|
24
31
|
} from "./contracts";
|
|
32
|
+
import { AppPwaCoreQueryError } from "./core-query-error";
|
|
25
33
|
import { appPwaQr, parseAppPwaQr } from "./protocol";
|
|
26
34
|
import { appPwaSchemaName, setupAppPwa } from "./schema";
|
|
27
35
|
import { withAppPwaTransaction } from "./transaction";
|
|
36
|
+
import { untilAppPwaAbort } from "./transport-input";
|
|
28
37
|
|
|
29
38
|
type Browser = { credential_hash: string; device_id: string | null; expires_at: Date };
|
|
30
39
|
type Device = {
|
|
@@ -121,6 +130,9 @@ const liveVisit = (visit: Visit, time: Date) =>
|
|
|
121
130
|
export const createAppPwaService = (options: AppPwaOptions) => {
|
|
122
131
|
if (typeof options.authorizeStaff !== "function" || typeof options.authorizeVisit !== "function")
|
|
123
132
|
throw new Error("App-PWA requires staff and visit-subject authorization");
|
|
133
|
+
const coreQueries = new Set(options.coreQueries ?? []);
|
|
134
|
+
if (!Array.isArray(options.coreQueries ?? []) || [...coreQueries].some((query) => query !== "address.autocomplete"))
|
|
135
|
+
throw new Error("Unsupported App-PWA Core query");
|
|
124
136
|
const db = options.db ?? sql;
|
|
125
137
|
const appId = options.app.id;
|
|
126
138
|
const path = options.app.installable.path;
|
|
@@ -343,6 +355,32 @@ export const createAppPwaService = (options: AppPwaOptions) => {
|
|
|
343
355
|
if (visit.device_id !== device.id || visit.browser_hash !== device.browser_hash) return pwaFail("NOT_FOUND", 404);
|
|
344
356
|
return visit;
|
|
345
357
|
};
|
|
358
|
+
const coreActor = async (tx: TransactionSQL, device: Device, beforeCommit: BeforeCommit, visitId?: string): Promise<AppPwaActor> => {
|
|
359
|
+
const currentVisit = visitId ?? device.active_visit_id;
|
|
360
|
+
if (!currentVisit) return deviceActor(device);
|
|
361
|
+
const visit = await boundVisit(tx, device, currentVisit);
|
|
362
|
+
if (device.active_visit_id !== visit.id) return pwaFail("REVOKED", 410);
|
|
363
|
+
// Device mode resumes after a visit ends. Explicit visit requests never fall back.
|
|
364
|
+
if (!visitId && !liveVisit(visit, await databaseClock(tx))) return deviceActor(device);
|
|
365
|
+
beforeCommit.deadline((time) => checkVisit(visit, time));
|
|
366
|
+
checkVisit(visit, await databaseClock(tx));
|
|
367
|
+
return visitActor(device, visit);
|
|
368
|
+
};
|
|
369
|
+
const recordCoreQuery = (actor: AppPwaActor, outcome: "allowed" | "denied" | "failed", code?: string, tx?: TransactionSQL) =>
|
|
370
|
+
audit.record(
|
|
371
|
+
{
|
|
372
|
+
action: "app_pwa.core.query",
|
|
373
|
+
outcome,
|
|
374
|
+
target: { type: "core.query", id: "address.autocomplete" },
|
|
375
|
+
...(code ? { error: { code } } : {}),
|
|
376
|
+
metadata: {
|
|
377
|
+
appId,
|
|
378
|
+
subject: { type: "user", userId: actor.ownerUserId },
|
|
379
|
+
via: { kind: actor.kind, appId, deviceId: actor.deviceId, ...(actor.kind === "visit" ? { visitId: actor.visitId } : {}) },
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
tx,
|
|
383
|
+
);
|
|
346
384
|
const readAttempt = async (tx: TransactionSQL, visitId: string, attemptId: string) => {
|
|
347
385
|
const [row] = await tx<Attempt[]>`SELECT route, input_digest, result FROM ${tables.attempts}
|
|
348
386
|
WHERE app_id = ${appId} AND visit_id = ${visitId}::uuid AND attempt_id = ${parse(AppPwaId, attemptId)}::uuid`;
|
|
@@ -627,6 +665,59 @@ export const createAppPwaService = (options: AppPwaOptions) => {
|
|
|
627
665
|
},
|
|
628
666
|
signal,
|
|
629
667
|
),
|
|
668
|
+
coreQuery: async (
|
|
669
|
+
credential: string,
|
|
670
|
+
visitId: string | undefined,
|
|
671
|
+
query: string,
|
|
672
|
+
input: unknown,
|
|
673
|
+
signal?: AbortSignal,
|
|
674
|
+
locale?: string,
|
|
675
|
+
) => {
|
|
676
|
+
const requestSignal = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(REQUEST_IDLE_TIMEOUT_SECONDS * 1000)]);
|
|
677
|
+
return untilAppPwaAbort(requestSignal, async () => {
|
|
678
|
+
if (query !== "address.autocomplete" || !coreQueries.has(query)) return pwaFail("FORBIDDEN", 403);
|
|
679
|
+
const value = parse(GeoAutocompleteInputSchema, bounded(input, options.budgets.requestBytes));
|
|
680
|
+
const actor = await guest(
|
|
681
|
+
credential,
|
|
682
|
+
(tx, device, _signal, beforeCommit) => coreActor(tx, device, beforeCommit, visitId),
|
|
683
|
+
requestSignal,
|
|
684
|
+
);
|
|
685
|
+
try {
|
|
686
|
+
// No pairing transaction or settings lock spans a pool checkout or provider request.
|
|
687
|
+
// Bound each device/visit by both the operator's request budget and the Geo plan's RPS.
|
|
688
|
+
const [requestRate, geoConfig] = await Promise.all([getSetting<number>("security.rate_limit_per_second"), readGeoConfig()]);
|
|
689
|
+
if (requestSignal.aborted) return pwaFail("UNAVAILABLE", 503);
|
|
690
|
+
const limiter = ratelimit({ id: `app-pwa:${appId}:core`, limit: Math.min(requestRate, geoPlan(geoConfig).rps) });
|
|
691
|
+
if ((await limiter.check(`device:${actor.deviceId}`)).limited) return pwaFail("RATE_LIMITED", 429);
|
|
692
|
+
if (actor.kind === "visit" && (await limiter.check(`visit:${actor.visitId}`)).limited) return pwaFail("RATE_LIMITED", 429);
|
|
693
|
+
if (requestSignal.aborted) return pwaFail("UNAVAILABLE", 503);
|
|
694
|
+
const result = bounded(await coreGeo.autocompleteForAppPwa(value, { actor, signal: requestSignal, locale }));
|
|
695
|
+
await guest(
|
|
696
|
+
credential,
|
|
697
|
+
async (tx, device, _signal, beforeCommit) => {
|
|
698
|
+
const current = await coreActor(tx, device, beforeCommit, actor.kind === "visit" ? actor.visitId : undefined);
|
|
699
|
+
// A newly opened visit changes device-mode authority; retry with its current status.
|
|
700
|
+
if (current.kind !== actor.kind) return pwaFail("UNAVAILABLE", 503);
|
|
701
|
+
if (current.deviceId !== actor.deviceId || current.ownerUserId !== actor.ownerUserId) return pwaFail("REVOKED", 410);
|
|
702
|
+
await recordCoreQuery(current, "allowed", undefined, tx);
|
|
703
|
+
return null;
|
|
704
|
+
},
|
|
705
|
+
requestSignal,
|
|
706
|
+
);
|
|
707
|
+
return result;
|
|
708
|
+
} catch (error) {
|
|
709
|
+
const code = error instanceof AppPwaError || error instanceof GeoError ? error.code : "UNAVAILABLE";
|
|
710
|
+
await recordCoreQuery(
|
|
711
|
+
actor,
|
|
712
|
+
error instanceof AppPwaError || (error instanceof GeoError && error.status < 500) ? "denied" : "failed",
|
|
713
|
+
code,
|
|
714
|
+
);
|
|
715
|
+
if (error instanceof GeoError) throw new AppPwaCoreQueryError(error);
|
|
716
|
+
if (error instanceof AppPwaError) throw error;
|
|
717
|
+
return pwaFail("UNAVAILABLE", 503);
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
},
|
|
630
721
|
query: (credential: string, visitId: string, route: string, input: unknown, signal?: AbortSignal) =>
|
|
631
722
|
guest(
|
|
632
723
|
credential,
|
package/src/app-pwa/transport.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { type Context, Hono } from "hono";
|
|
2
2
|
import { setCookie } from "hono/cookie";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { resolveLocale } from "../server/locale";
|
|
4
5
|
import type { AuthContext } from "../server/middleware/auth";
|
|
5
6
|
import { rateLimit } from "../server/middleware/rate-limit";
|
|
6
7
|
import { REQUEST_IDLE_TIMEOUT_SECONDS } from "../server/request-timeout";
|
|
7
8
|
import { pairingSecret } from "../services/pairing-secret";
|
|
8
9
|
import { session } from "../services/session";
|
|
10
|
+
import { get as getSetting } from "../services/settings";
|
|
9
11
|
import {
|
|
10
12
|
AppPwaAttempt,
|
|
11
13
|
AppPwaClaim,
|
|
@@ -17,6 +19,7 @@ import {
|
|
|
17
19
|
parseAppPwaInput as parse,
|
|
18
20
|
pwaFail,
|
|
19
21
|
} from "./contracts";
|
|
22
|
+
import { AppPwaCoreQueryError } from "./core-query-error";
|
|
20
23
|
import { type AppPwaService, createAppPwaService } from "./service";
|
|
21
24
|
import { readAppPwaJson, untilAppPwaAbort } from "./transport-input";
|
|
22
25
|
|
|
@@ -57,6 +60,11 @@ export const createAppPwaRouter = (service: AppPwaService) => {
|
|
|
57
60
|
const router = new Hono<AuthContext>();
|
|
58
61
|
const limiter = rateLimit({ keyBy: "ip" });
|
|
59
62
|
router.onError((error, c) => {
|
|
63
|
+
if (error instanceof AppPwaCoreQueryError)
|
|
64
|
+
return Response.json(
|
|
65
|
+
{ code: error.code, ...(error.details ? { details: error.details } : {}) },
|
|
66
|
+
{ status: error.status, headers: c.res.headers },
|
|
67
|
+
);
|
|
60
68
|
const safe = error instanceof AppPwaError ? error : new AppPwaError("UNAVAILABLE", 503);
|
|
61
69
|
return c.json({ code: safe.code }, safe.status);
|
|
62
70
|
});
|
|
@@ -130,6 +138,20 @@ export const createAppPwaRouter = (service: AppPwaService) => {
|
|
|
130
138
|
const input = parse(z.strictObject({ visitId: AppPwaId, attemptId: AppPwaId }), await body(c));
|
|
131
139
|
return c.json(await service.attempt(key, input.visitId, input.attemptId, c.req.raw.signal));
|
|
132
140
|
});
|
|
141
|
+
router.post(`${guestBase}/core/query/:query`, async (c) => {
|
|
142
|
+
const key = credential(c);
|
|
143
|
+
const input = parse(z.strictObject({ visitId: AppPwaId.optional(), input: z.unknown() }), await body(c));
|
|
144
|
+
return c.json(
|
|
145
|
+
await service.coreQuery(
|
|
146
|
+
key,
|
|
147
|
+
input.visitId,
|
|
148
|
+
c.req.param("query"),
|
|
149
|
+
input.input,
|
|
150
|
+
c.req.raw.signal,
|
|
151
|
+
resolveLocale(c.req.raw.headers, await getSetting<string>("app.locale")),
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
});
|
|
133
155
|
router.post(`${guestBase}/query/:route`, async (c) => {
|
|
134
156
|
const key = credential(c);
|
|
135
157
|
const input = parse(queryInput, await body(c));
|
|
@@ -10,7 +10,7 @@ export const approvalMessages = i18n.define({
|
|
|
10
10
|
rejectAllTitle: "Reject all open approvals?",
|
|
11
11
|
rejectAllBody: ({ count }: { count: number }) =>
|
|
12
12
|
`${count === 1 ? "The open Action does not run." : `None of the ${count.toLocaleString("en")} open Actions runs.`} Where Cloud asked, it continues with your rejection.`,
|
|
13
|
-
emptyTitle: "
|
|
13
|
+
emptyTitle: "You're all set.",
|
|
14
14
|
emptyBody: "When an Action needs your approval, it appears here and in the chat it comes from.",
|
|
15
15
|
loading: "Loading approvals…",
|
|
16
16
|
loadFailed: "Approvals could not be loaded.",
|
|
@@ -48,7 +48,7 @@ export const approvalMessages = i18n.define({
|
|
|
48
48
|
rejectAllTitle: "Alle offenen Freigaben ablehnen?",
|
|
49
49
|
rejectAllBody: ({ count }) =>
|
|
50
50
|
`${count === 1 ? "Die offene Aktion läuft nicht." : `Keine der ${count.toLocaleString("de")} offenen Aktionen läuft.`} Wo Cloud gefragt hat, geht es mit deiner Ablehnung weiter.`,
|
|
51
|
-
emptyTitle: "
|
|
51
|
+
emptyTitle: "Alles erledigt.",
|
|
52
52
|
emptyBody: "Wenn eine Aktion deine Freigabe braucht, erscheint sie hier und im Chat, aus dem sie kommt.",
|
|
53
53
|
loading: "Freigaben werden geladen …",
|
|
54
54
|
loadFailed: "Die Freigaben konnten nicht geladen werden.",
|